@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.2

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 (138) hide show
  1. package/AUTHORING.md +134 -0
  2. package/CHANGELOG.md +61 -0
  3. package/README.md +21 -9
  4. package/SUBMISSION.md +10 -11
  5. package/bin/apifuse-pack-check.ts +22 -0
  6. package/bin/apifuse-submit-check.ts +1747 -388
  7. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  8. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  9. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  10. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  13. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  14. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  15. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  16. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  17. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  18. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  19. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  20. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  21. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  22. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  23. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  24. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  25. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  26. package/dist/auth-turn/index.d.ts +195 -0
  27. package/dist/auth-turn/index.js +133 -0
  28. package/dist/auth.d.ts +76 -0
  29. package/dist/auth.js +436 -0
  30. package/dist/ceremonies/index.js +7 -31
  31. package/dist/cli/create.js +45 -30
  32. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  33. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  34. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  35. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  36. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  37. package/dist/cli/templates/provider/README.md.tpl +163 -0
  38. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  39. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  40. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  41. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  42. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  43. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  44. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  46. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  48. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  49. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  50. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  51. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  52. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  53. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  54. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  55. package/dist/contract.js +1 -0
  56. package/dist/define.d.ts +6 -1
  57. package/dist/define.js +140 -70
  58. package/dist/index.d.ts +3 -2
  59. package/dist/index.js +2 -1
  60. package/dist/lint.d.ts +1 -0
  61. package/dist/lint.js +27 -0
  62. package/dist/provider.d.ts +4 -2
  63. package/dist/provider.js +2 -1
  64. package/dist/runtime/auth-flow.js +2 -0
  65. package/dist/runtime/browser.js +203 -0
  66. package/dist/runtime/http.js +46 -336
  67. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  68. package/dist/runtime/proxy-retry-policy.js +326 -0
  69. package/dist/runtime/stealth.d.ts +5 -2
  70. package/dist/runtime/stealth.js +206 -206
  71. package/dist/server/index.d.ts +4 -0
  72. package/dist/server/index.js +4 -0
  73. package/dist/server/self-test-input-tokens.d.ts +1 -0
  74. package/dist/server/self-test-input-tokens.js +37 -0
  75. package/dist/server/self-test-redaction.d.ts +20 -0
  76. package/dist/server/self-test-redaction.js +70 -0
  77. package/dist/server/self-test-token.d.ts +30 -0
  78. package/dist/server/self-test-token.js +50 -0
  79. package/dist/server/self-test.d.ts +98 -0
  80. package/dist/server/self-test.js +555 -0
  81. package/dist/server/serve.d.ts +6 -0
  82. package/dist/server/serve.js +38 -10
  83. package/dist/server/types.d.ts +1 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/testing/run.js +5 -1
  86. package/dist/types.d.ts +150 -0
  87. package/package.json +9 -2
  88. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  89. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  90. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  91. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  92. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  93. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  94. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  95. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  96. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  97. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  98. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  99. package/src/auth-turn/fixtures/valid/form.json +14 -0
  100. package/src/auth-turn/fixtures/valid/message.json +13 -0
  101. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  102. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  103. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  104. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  105. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  106. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  107. package/src/auth-turn/index.ts +177 -0
  108. package/src/auth.ts +786 -0
  109. package/src/ceremonies/index.ts +9 -43
  110. package/src/cli/create.ts +60 -97
  111. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  112. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  113. package/src/cli/templates/provider/README.md.tpl +7 -4
  114. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  115. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  116. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  117. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  118. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  119. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  120. package/src/contract.ts +1 -0
  121. package/src/define.ts +198 -71
  122. package/src/index.ts +16 -0
  123. package/src/lint.ts +33 -0
  124. package/src/provider.ts +27 -0
  125. package/src/runtime/auth-flow.ts +2 -0
  126. package/src/runtime/browser.ts +293 -1
  127. package/src/runtime/http.ts +84 -530
  128. package/src/runtime/proxy-retry-policy.ts +469 -0
  129. package/src/runtime/stealth.ts +269 -353
  130. package/src/server/index.ts +36 -0
  131. package/src/server/self-test-input-tokens.ts +46 -0
  132. package/src/server/self-test-redaction.ts +97 -0
  133. package/src/server/self-test-token.ts +70 -0
  134. package/src/server/self-test.ts +725 -0
  135. package/src/server/serve.ts +75 -6
  136. package/src/server/types.ts +1 -0
  137. package/src/testing/run.ts +9 -1
  138. package/src/types.ts +186 -0
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: normalization-standards
3
+ description: Public output contract rules — field naming, timestamps, enums, units, nullability. Load before writing or editing any output schema or mapper.
4
+ ---
5
+
6
+ # Normalization standards
7
+
8
+ Public output is the product. Apply these to every schema + mapper pair.
9
+
10
+ ## Field naming
11
+ - `snake_case`, English, semantic. `emergency_phone`, not `dutyTel3`.
12
+ - Never expose vendor key vocabularies (`hv1`..`hv12`, `MKioskTy*`, `duty*`)
13
+ as public field names OR as dynamic record keys. A
14
+ `z.record(z.string(), ...)` keyed by vendor codes is still a vendor leak —
15
+ map codes to a stable public vocabulary (enum keys or an array of
16
+ `{ code, label, status }` objects with normalized status).
17
+
18
+ ## Timestamps and dates
19
+ - Public: ISO 8601 (`2026-07-07T22:28:55+09:00`, dates `2026-07-07`,
20
+ clock times `HH:MM`). Include the upstream's timezone offset; Korean public
21
+ APIs are KST (+09:00) — verify, then encode it.
22
+ - Vendor formats (`YYYYMMDDHHmmss`, `HHmm`, unpadded `900`) are parsed inside
23
+ the mapper. Unparseable → `null`, plus a test for that row shape.
24
+ - Never emit a raw vendor timestamp string in public output, including
25
+ fixtures.
26
+
27
+ ## Enums
28
+ - Vendor status values (codes, `Y`/`N`, Korean labels like `불가능`,
29
+ `정보미제공`) → declared `z.enum`. Unknown value → explicit `unknown` member
30
+ or fail closed; never pass raw text through.
31
+ - Map from the OFFICIAL code table, not from guessing what live samples mean.
32
+ Add a regression test per enum member.
33
+
34
+ ## Numbers and units
35
+ - Field name states the unit: `distance_meters`, `radius_meters`,
36
+ `price_krw`. Mapper proves the conversion (upstream km → `* 1000`).
37
+ - Verify the upstream unit from spec or live-data sanity check (a "distance"
38
+ of `1.2` from a nearby search is km, not meters). Sibling endpoints of the
39
+ same vendor may differ — verify each one.
40
+ - Value-domain constraints (`nonnegative`, `min`, `max`) must reflect the
41
+ upstream's REAL domain observed in live data, not what seems sensible.
42
+ A wrong `nonnegative()` turns real negative values into `null`/errors
43
+ silently.
44
+
45
+ ## Nullability
46
+ - `null` means "upstream did not provide it" — never "parsing failed" and
47
+ never a placeholder for invented data.
48
+ - Required-for-identity fields (ids, names) missing → throw
49
+ `UPSTREAM_SCHEMA_ERROR`; do not emit partial rows.
50
+
51
+ ## Checklist before submitting a schema/mapper change
52
+ - [ ] No vendor key visible in any public field name or record key
53
+ - [ ] All timestamps ISO 8601 with timezone; parsing tested for real vendor
54
+ shapes (padded/unpadded, string/number)
55
+ - [ ] All status-like strings are declared enums with official-table mapping
56
+ - [ ] Every numeric field's unit is in its name and conversion is tested
57
+ - [ ] Constraints checked against live data, not intuition
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: pagination-and-counts
3
+ description: total_count semantics, client-side filtering, and paging honesty. Load before implementing any list/search operation.
4
+ ---
5
+
6
+ # Pagination and counts
7
+
8
+ A caller uses `total_count`, `page`, and `limit` to plan iteration. If those
9
+ numbers don't describe what the caller can actually page through, the
10
+ operation is lying.
11
+
12
+ ## The client-side filtering trap
13
+ If the upstream has no server-side filter for one of your inputs (e.g. no
14
+ radius param) and you filter rows after fetching:
15
+
16
+ - Upstream `totalCount` counts UNFILTERED rows. Returning it as your
17
+ `total_count` while returning filtered rows means: caller sees
18
+ `total_count: 541`, gets 1 row on page 1, and pages 2..28 return rows that
19
+ are outside the filter or empty. This is a contract failure, not a nuance.
20
+
21
+ Acceptable resolutions, in preference order:
22
+ 1. **Don't accept the input.** If the upstream can't filter by it and you
23
+ can't enumerate all pages, drop the input from the schema and document the
24
+ upstream's real semantics (e.g. "results are distance-sorted; no radius
25
+ cutoff").
26
+ 2. **Expose upstream semantics honestly.** Distance-sorted paging with a
27
+ documented "no radius filter" contract and no fake `radius` input.
28
+ 3. **Filter AND fix the metadata.** If you must filter client-side, do not
29
+ return the upstream total. Return only what you can prove (`returned_count`
30
+ plus a `has_more` you can actually compute) and document that totals are
31
+ unavailable.
32
+
33
+ Never combine: accepted filter input + client-side filter + upstream total.
34
+
35
+ ## Count integrity
36
+ - Parse failure of `totalCount` → `UPSTREAM_SCHEMA_ERROR`, not `0`.
37
+ A fail-open zero disguises upstream drift as an empty dataset.
38
+ - If `total_count > 0` but the page's row array normalizes to empty on
39
+ page 1, throw — that combination means broken extraction, not empty data.
40
+
41
+ ## Page/limit echo
42
+ - Echo the EFFECTIVE values: if you clamp `limit` to the upstream max, return
43
+ the clamped value, not the requested one.
44
+ - `page`/`limit` semantics must match the upstream's paging model
45
+ (1-indexed vs 0-indexed) — verify with two live pages, checking the
46
+ returned rows actually differ.
47
+
48
+ ## Checklist
49
+ - [ ] No input is filtered client-side while `total_count` comes from upstream
50
+ - [ ] totalCount parse failure fails closed
51
+ - [ ] Effective (clamped) limit echoed
52
+ - [ ] Two-page live check proves paging advances
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: upstream-contract-verification
3
+ description: How to establish evidence for upstream request params and response fields before coding. Load before wiring any new endpoint or mapping new fields.
4
+ ---
5
+
6
+ # Upstream contract verification
7
+
8
+ Most provider P0s come from guessed upstream contracts. Every param name and
9
+ response field needs evidence BEFORE it ships.
10
+
11
+ ## Request parameters
12
+ 1. Start from the official spec document (data.go.kr 활용가이드, vendor API
13
+ docs). Copy exact names — casing and underscores matter
14
+ (`WGS84_LAT` ≠ `WGS84LAT`; the wrong one is often silently ignored).
15
+ 2. Confirm with ONE live call per endpoint. A param being ignored does not
16
+ produce an error — it produces plausible-looking wrong results, so compare:
17
+ - filtered vs unfiltered `totalCount` (identical → param ignored)
18
+ - a dense-area query returning 0 rows (→ param name/format wrong)
19
+ 3. Do NOT copy param names from a sibling endpoint or sibling API of the same
20
+ vendor without re-verifying. Same vendor ≠ same contract; endpoints drift.
21
+ 4. If a param only works together with another param (district requires
22
+ province), encode that dependency in the input schema with a clear error.
23
+ Test it: dependent-param-alone must be rejected, not silently national.
24
+
25
+ ## Response fields
26
+ - Map exactly the field names present in your recorded live fixtures.
27
+ - No speculative fallback chains (`row.distance ?? row.dist ?? row.Distance`).
28
+ If two shapes genuinely exist, you need a recorded fixture proving EACH
29
+ branch plus a row-level test per branch; otherwise map one name only.
30
+ - Field presence varies by endpoint within the same vendor. Detail endpoints
31
+ often return more/differently-named fields than list endpoints — record
32
+ fixtures per endpoint, not per vendor.
33
+
34
+ ## When results look wrong
35
+ - Same response body across different request payloads → the upstream is
36
+ ignoring your variation; stop tuning fields and re-check param names/auth.
37
+ - Empty result for a query that must have data (city-center radius search,
38
+ major-district listing) → treat as a request bug. Never record it as a
39
+ fixture and never ship it.
40
+
41
+ ## Deliverables per endpoint
42
+ - [ ] Spec reference (URL or doc name + section) noted in the PR/commit
43
+ - [ ] One recorded live fixture proving request params take effect
44
+ - [ ] Negative evidence checked: filtered count differs from unfiltered
45
+ - [ ] Param dependencies enforced in the input schema with tests
@@ -0,0 +1,13 @@
1
+ # Upstream notes
2
+
3
+ Per-vendor / per-API-family pitfalls proven by live evidence. These are the
4
+ highest-value files in this workspace: general principles are knowable, but
5
+ "this API silently ignores param X" is only discoverable by getting burned.
6
+
7
+ - Read EVERY file here before your first upstream call.
8
+ - When you discover a new upstream quirk (silently ignored param, unit
9
+ surprise, undocumented value domain, error-shape oddity), ADD it here in
10
+ the same format — evidence line included. Reviewers treat contributed
11
+ upstream notes as part of submission quality.
12
+
13
+ Format per entry: **Symptom → Cause → Rule → Evidence**.
package/src/contract.ts CHANGED
@@ -170,6 +170,7 @@ function extractHealthCheck(
170
170
  if (!value) return undefined;
171
171
  return compactObject({
172
172
  interval: value.interval,
173
+ schedule: toJsonValue(value.schedule),
173
174
  timeoutMs: value.timeoutMs,
174
175
  degradedThresholdMs: value.degradedThresholdMs,
175
176
  requiresConnection: value.requiresConnection,
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 {
@@ -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,
@@ -118,49 +121,25 @@ const VALID_OPERATION_TRANSPORT_KINDS = [
118
121
  const SSE_EVENT_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
119
122
  const WEBSOCKET_SUBPROTOCOL_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
120
123
 
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
124
  const MS_DURATION_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)?$/;
155
125
 
156
126
  function isPositiveMsDurationString(value: unknown): value is string {
157
127
  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);
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;
164
143
  }
165
144
 
166
145
  type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
@@ -285,6 +264,8 @@ export interface ProviderConfig<
285
264
  };
286
265
  operations: OperationMapConfig<TOperations>;
287
266
  healthMonitor?: ProviderHealthMonitorConfig;
267
+ /** New name for `healthMonitor` (transitional alias); declaring both is a ValidationError. */
268
+ healthProbe?: ProviderHealthMonitorConfig;
288
269
  healthJourneys?: readonly HealthJourneyDefinition[];
289
270
  }
290
271
 
@@ -374,6 +355,14 @@ function validateProviderShape(config: unknown): void {
374
355
  VALID_AUTH_MODES,
375
356
  String(config.id),
376
357
  );
358
+ if (auth && typeof auth === "object" && "exchange" in auth) {
359
+ throw new ProviderError(
360
+ `Provider "${String(config.id)}" auth.exchange is not part of the Provider SDK auth contract`,
361
+ {
362
+ 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.",
363
+ },
364
+ );
365
+ }
377
366
  if (
378
367
  auth &&
379
368
  typeof auth === "object" &&
@@ -1116,6 +1105,7 @@ function validateOperationTransports(
1116
1105
 
1117
1106
  const HEALTH_CHECK_SUITE_FIELDS = new Set([
1118
1107
  "interval",
1108
+ "schedule",
1119
1109
  "timeoutMs",
1120
1110
  "degradedThresholdMs",
1121
1111
  "cases",
@@ -1225,6 +1215,7 @@ function assertBoundedIntegerMs(
1225
1215
  function validateProviderHealthMonitor(
1226
1216
  providerId: string,
1227
1217
  healthMonitor: unknown,
1218
+ field: "healthMonitor" | "healthProbe" = "healthMonitor",
1228
1219
  ): void {
1229
1220
  if (healthMonitor === undefined) return;
1230
1221
  if (
@@ -1233,21 +1224,21 @@ function validateProviderHealthMonitor(
1233
1224
  Array.isArray(healthMonitor)
1234
1225
  )
1235
1226
  throw new ValidationError(
1236
- `Provider "${providerId}" has invalid healthMonitor: must be an object.`,
1227
+ `Provider "${providerId}" has invalid ${field}: must be an object.`,
1237
1228
  {
1238
- fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
1229
+ fix: `Set ${field} to { requiredSecrets?: string[]; serviceAccount?: string }`,
1239
1230
  },
1240
1231
  );
1241
1232
  const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
1242
1233
  rejectUnknownFields(
1243
1234
  healthMonitorRecord,
1244
1235
  PROVIDER_HEALTH_MONITOR_FIELDS,
1245
- "healthMonitor",
1236
+ field,
1246
1237
  );
1247
1238
  if (healthMonitorRecord.defaultProbeTimeoutMs !== undefined) {
1248
1239
  assertBoundedIntegerMs(
1249
1240
  healthMonitorRecord.defaultProbeTimeoutMs,
1250
- `Provider "${providerId}" healthMonitor.defaultProbeTimeoutMs`,
1241
+ `Provider "${providerId}" ${field}.defaultProbeTimeoutMs`,
1251
1242
  {
1252
1243
  min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1253
1244
  max: HEALTH_CHECK_TIMEOUT_MS_MAX,
@@ -1258,7 +1249,7 @@ function validateProviderHealthMonitor(
1258
1249
  if (healthMonitorRecord.defaultDegradedThresholdMs !== undefined) {
1259
1250
  assertBoundedIntegerMs(
1260
1251
  healthMonitorRecord.defaultDegradedThresholdMs,
1261
- `Provider "${providerId}" healthMonitor.defaultDegradedThresholdMs`,
1252
+ `Provider "${providerId}" ${field}.defaultDegradedThresholdMs`,
1262
1253
  {
1263
1254
  min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1264
1255
  max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
@@ -1270,12 +1261,12 @@ function validateProviderHealthMonitor(
1270
1261
  if (requiredSecrets !== undefined) {
1271
1262
  if (!Array.isArray(requiredSecrets))
1272
1263
  throw new ValidationError(
1273
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
1264
+ `Provider "${providerId}" has invalid ${field}.requiredSecrets: must be string[].`,
1274
1265
  );
1275
1266
  for (const [index, secret] of requiredSecrets.entries()) {
1276
1267
  if (typeof secret !== "string" || secret.length === 0)
1277
1268
  throw new ValidationError(
1278
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
1269
+ `Provider "${providerId}" has invalid ${field}.requiredSecrets[${index}]: must be a non-empty string.`,
1279
1270
  );
1280
1271
  }
1281
1272
  }
@@ -1287,23 +1278,23 @@ function validateProviderHealthMonitor(
1287
1278
  Array.isArray(credentialInputs)
1288
1279
  ) {
1289
1280
  throw new ValidationError(
1290
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs: must be an object mapping auth input fields to env var names.`,
1281
+ `Provider "${providerId}" has invalid ${field}.credentialInputs: must be an object mapping auth input fields to env var names.`,
1291
1282
  );
1292
1283
  }
1293
- for (const [field, envVar] of Object.entries(credentialInputs)) {
1294
- if (field.trim().length === 0) {
1284
+ for (const [inputField, envVar] of Object.entries(credentialInputs)) {
1285
+ if (inputField.trim().length === 0) {
1295
1286
  throw new ValidationError(
1296
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs key: must be a non-empty auth input field.`,
1287
+ `Provider "${providerId}" has invalid ${field}.credentialInputs key: must be a non-empty auth input field.`,
1297
1288
  );
1298
1289
  }
1299
1290
  if (typeof envVar !== "string" || envVar.trim().length === 0) {
1300
1291
  throw new ValidationError(
1301
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs.${field}: must be a non-empty env var name.`,
1292
+ `Provider "${providerId}" has invalid ${field}.credentialInputs.${inputField}: must be a non-empty env var name.`,
1302
1293
  );
1303
1294
  }
1304
1295
  if (Array.isArray(requiredSecrets) && !requiredSecrets.includes(envVar)) {
1305
1296
  throw new ValidationError(
1306
- `Provider "${providerId}" healthMonitor.credentialInputs.${field} references ${envVar}, which must also be listed in healthMonitor.requiredSecrets.`,
1297
+ `Provider "${providerId}" ${field}.credentialInputs.${inputField} references ${envVar}, which must also be listed in ${field}.requiredSecrets.`,
1307
1298
  );
1308
1299
  }
1309
1300
  }
@@ -1317,32 +1308,32 @@ function validateProviderHealthMonitor(
1317
1308
  Array.isArray(probeOverrides)
1318
1309
  )
1319
1310
  throw new ValidationError(
1320
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
1311
+ `Provider "${providerId}" has invalid ${field}.probeOverrides: must be an object keyed by probe id.`,
1321
1312
  );
1322
1313
  for (const [probeId, override] of Object.entries(probeOverrides)) {
1323
1314
  if (probeId.length === 0)
1324
1315
  throw new ValidationError(
1325
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
1316
+ `Provider "${providerId}" has invalid ${field}.probeOverrides key: must be a non-empty probe id.`,
1326
1317
  );
1327
1318
  if (!override || typeof override !== "object" || Array.isArray(override))
1328
1319
  throw new ValidationError(
1329
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
1320
+ `Provider "${providerId}" has invalid ${field}.probeOverrides["${probeId}"]: must be an object.`,
1330
1321
  );
1331
1322
  const overrideRecord = Object.fromEntries(Object.entries(override));
1332
1323
  rejectUnknownFields(
1333
1324
  overrideRecord,
1334
1325
  PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
1335
- `healthMonitor.probeOverrides["${probeId}"]`,
1326
+ `${field}.probeOverrides["${probeId}"]`,
1336
1327
  );
1337
1328
  const interval = overrideRecord.interval;
1338
1329
  if (interval !== undefined && !isPositiveMsDurationString(interval))
1339
1330
  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.`,
1331
+ `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
1332
  );
1342
1333
  if (overrideRecord.timeoutMs !== undefined) {
1343
1334
  assertBoundedIntegerMs(
1344
1335
  overrideRecord.timeoutMs,
1345
- `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].timeoutMs`,
1336
+ `Provider "${providerId}" ${field}.probeOverrides["${probeId}"].timeoutMs`,
1346
1337
  {
1347
1338
  min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1348
1339
  max: HEALTH_CHECK_TIMEOUT_MS_MAX,
@@ -1353,7 +1344,7 @@ function validateProviderHealthMonitor(
1353
1344
  if (overrideRecord.degradedThresholdMs !== undefined) {
1354
1345
  assertBoundedIntegerMs(
1355
1346
  overrideRecord.degradedThresholdMs,
1356
- `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].degradedThresholdMs`,
1347
+ `Provider "${providerId}" ${field}.probeOverrides["${probeId}"].degradedThresholdMs`,
1357
1348
  {
1358
1349
  min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1359
1350
  max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
@@ -1369,7 +1360,7 @@ function validateProviderHealthMonitor(
1369
1360
  (typeof serviceAccount !== "string" || serviceAccount.length === 0)
1370
1361
  )
1371
1362
  throw new ValidationError(
1372
- `Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
1363
+ `Provider "${providerId}" has invalid ${field}.serviceAccount: must be a non-empty string.`,
1373
1364
  );
1374
1365
  }
1375
1366
 
@@ -1463,6 +1454,35 @@ function validateHealthCheckSuite(
1463
1454
  fix: `Set ${fieldPath}.interval to a positive ms-style duration string.`,
1464
1455
  },
1465
1456
  );
1457
+ if (s.schedule !== undefined) {
1458
+ if (
1459
+ !s.schedule ||
1460
+ typeof s.schedule !== "object" ||
1461
+ Array.isArray(s.schedule)
1462
+ ) {
1463
+ throw new ValidationError(
1464
+ `Provider "${providerId}" ${fieldPath}.schedule must be an object.`,
1465
+ );
1466
+ }
1467
+ if (Reflect.get(s.schedule, "jitter") !== undefined) {
1468
+ throw new ValidationError(
1469
+ `Provider "${providerId}" ${fieldPath}.schedule.jitter is not supported for operation healthCheck schedules. Use schedule.randomize instead.`,
1470
+ );
1471
+ }
1472
+ rejectUnknownFields(
1473
+ s.schedule,
1474
+ new Set(["randomize"]),
1475
+ `${fieldPath}.schedule`,
1476
+ );
1477
+ const randomize = Reflect.get(s.schedule, "randomize");
1478
+ if (randomize !== undefined) {
1479
+ validateScheduleRandomization(
1480
+ randomize,
1481
+ `Provider "${providerId}" ${fieldPath}.schedule.randomize`,
1482
+ msDurationMs(s.interval),
1483
+ );
1484
+ }
1485
+ }
1466
1486
  if (s.timeoutMs !== undefined) {
1467
1487
  assertBoundedIntegerMs(
1468
1488
  s.timeoutMs,
@@ -1561,7 +1581,12 @@ const HEALTH_JOURNEY_FIELDS = new Set([
1561
1581
  "steps",
1562
1582
  "run",
1563
1583
  ]);
1564
- const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter"]);
1584
+ const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set([
1585
+ "kind",
1586
+ "interval",
1587
+ "jitter",
1588
+ "randomize",
1589
+ ]);
1565
1590
  const HEALTH_JOURNEY_STEP_FIELDS = new Set([
1566
1591
  "id",
1567
1592
  "description",
@@ -1734,6 +1759,54 @@ function isoDurationMs(value: string): number {
1734
1759
  );
1735
1760
  }
1736
1761
 
1762
+ function scheduleRandomizationMs(
1763
+ randomize: unknown,
1764
+ fieldPath: string,
1765
+ ): number {
1766
+ const mode = Reflect.get(randomize as object, "mode");
1767
+ switch (mode) {
1768
+ case "centered": {
1769
+ const maxOffset = Reflect.get(randomize as object, "maxOffset");
1770
+ assertIsoDuration(maxOffset, `${fieldPath}.maxOffset`);
1771
+ return isoDurationMs(maxOffset);
1772
+ }
1773
+ case "delayed": {
1774
+ const maxDelay = Reflect.get(randomize as object, "maxDelay");
1775
+ assertIsoDuration(maxDelay, `${fieldPath}.maxDelay`);
1776
+ return isoDurationMs(maxDelay);
1777
+ }
1778
+ default:
1779
+ throw new ValidationError(
1780
+ `${fieldPath}.mode must be "centered" or "delayed".`,
1781
+ );
1782
+ }
1783
+ }
1784
+
1785
+ function validateScheduleRandomization(
1786
+ randomize: unknown,
1787
+ fieldPath: string,
1788
+ intervalMs: number,
1789
+ ): void {
1790
+ if (!randomize || typeof randomize !== "object" || Array.isArray(randomize)) {
1791
+ throw new ValidationError(`${fieldPath} must be an object.`);
1792
+ }
1793
+ const mode = Reflect.get(randomize, "mode");
1794
+ const allowedFields =
1795
+ mode === "centered"
1796
+ ? new Set(["mode", "maxOffset"])
1797
+ : new Set(["mode", "maxDelay"]);
1798
+ rejectUnknownFields(randomize, allowedFields, fieldPath);
1799
+ const offsetMs = scheduleRandomizationMs(randomize, fieldPath);
1800
+ if (offsetMs <= 0) {
1801
+ throw new ValidationError(`${fieldPath} duration must be positive.`);
1802
+ }
1803
+ if (offsetMs >= intervalMs) {
1804
+ throw new ValidationError(
1805
+ `${fieldPath} duration must be shorter than schedule interval.`,
1806
+ );
1807
+ }
1808
+ }
1809
+
1737
1810
  function assertIsoCountry(
1738
1811
  value: unknown,
1739
1812
  fieldPath: string,
@@ -1749,13 +1822,21 @@ function normalizeIntervalDuration(input: string): string {
1749
1822
  const trimmed = input.trim();
1750
1823
  const shorthand = /^(\d+)(s|m|h|d)$/i.exec(trimmed);
1751
1824
  if (shorthand) {
1752
- const amount = Number(shorthand[1]);
1825
+ const durationMs = msDurationMs(trimmed);
1826
+ const unit = shorthand[2]?.toLowerCase();
1827
+ const amount =
1828
+ unit === "s"
1829
+ ? durationMs / 1_000
1830
+ : unit === "m"
1831
+ ? durationMs / 60_000
1832
+ : unit === "h"
1833
+ ? durationMs / 3_600_000
1834
+ : durationMs / 86_400_000;
1753
1835
  if (!Number.isInteger(amount) || amount <= 0) {
1754
1836
  throw new ValidationError(
1755
1837
  `Journey schedule interval must be a positive duration.`,
1756
1838
  );
1757
1839
  }
1758
- const unit = shorthand[2]?.toLowerCase();
1759
1840
  if (unit === "s") return `PT${amount}S`;
1760
1841
  if (unit === "m") return `PT${amount}M`;
1761
1842
  if (unit === "h") return `PT${amount}H`;
@@ -1767,18 +1848,34 @@ function normalizeIntervalDuration(input: string): string {
1767
1848
 
1768
1849
  export function every(
1769
1850
  interval: string,
1770
- options: { jitter?: string } = {},
1851
+ options: { jitter?: string; randomize?: HealthScheduleRandomization } = {},
1771
1852
  ): HealthJourneySchedule {
1853
+ if (options.jitter !== undefined && options.randomize !== undefined) {
1854
+ throw new ValidationError(
1855
+ `Schedule cannot define both jitter and randomize. Use randomize instead.`,
1856
+ );
1857
+ }
1772
1858
  const schedule: HealthJourneySchedule = {
1773
1859
  kind: "interval",
1774
1860
  interval: normalizeIntervalDuration(interval),
1775
1861
  };
1862
+ if (options.randomize !== undefined) {
1863
+ schedule.randomize = options.randomize;
1864
+ }
1776
1865
  if (options.jitter !== undefined) {
1777
1866
  schedule.jitter = normalizeIntervalDuration(options.jitter);
1778
1867
  }
1779
1868
  return schedule;
1780
1869
  }
1781
1870
 
1871
+ export function centered(maxOffset: string): HealthScheduleRandomization {
1872
+ return { mode: "centered", maxOffset: normalizeIntervalDuration(maxOffset) };
1873
+ }
1874
+
1875
+ export function delayed(maxDelay: string): HealthScheduleRandomization {
1876
+ return { mode: "delayed", maxDelay: normalizeIntervalDuration(maxDelay) };
1877
+ }
1878
+
1782
1879
  function countCapturingGroups(pattern: RegExp): number {
1783
1880
  let count = 0;
1784
1881
  const source = pattern.source;
@@ -1993,15 +2090,29 @@ function validateHealthJourneySchedule(
1993
2090
  throw new ValidationError(
1994
2091
  `Provider "${providerId}" ${fieldPath}.kind must be "interval".`,
1995
2092
  );
1996
- assertIsoDuration(
1997
- Reflect.get(schedule, "interval"),
1998
- `Provider "${providerId}" ${fieldPath}.interval`,
1999
- );
2093
+ const interval = Reflect.get(schedule, "interval");
2094
+ assertIsoDuration(interval, `Provider "${providerId}" ${fieldPath}.interval`);
2095
+ const randomize = Reflect.get(schedule, "randomize");
2096
+ if (
2097
+ Reflect.get(schedule, "jitter") !== undefined &&
2098
+ randomize !== undefined
2099
+ ) {
2100
+ throw new ValidationError(
2101
+ `Provider "${providerId}" ${fieldPath} cannot define both jitter and randomize.`,
2102
+ );
2103
+ }
2000
2104
  if (Reflect.get(schedule, "jitter") !== undefined)
2001
2105
  assertIsoDuration(
2002
2106
  Reflect.get(schedule, "jitter"),
2003
2107
  `Provider "${providerId}" ${fieldPath}.jitter`,
2004
2108
  );
2109
+ if (randomize !== undefined) {
2110
+ validateScheduleRandomization(
2111
+ randomize,
2112
+ `Provider "${providerId}" ${fieldPath}.randomize`,
2113
+ isoDurationMs(interval),
2114
+ );
2115
+ }
2005
2116
  }
2006
2117
 
2007
2118
  function validateHealthJourneys(
@@ -2244,7 +2355,9 @@ export function defineProvider<
2244
2355
  if (Object.keys(config.operations).length === 0)
2245
2356
  throw new ProviderError(
2246
2357
  `Provider "${config.id}" must define at least one operation`,
2247
- { fix: "Add at least one operation to the operations object" },
2358
+ {
2359
+ fix: "Add at least one operation to the operations object",
2360
+ },
2248
2361
  );
2249
2362
  validateOperationIds(config.id, config.operations);
2250
2363
  validateOperationAnnotations(config.id, config.operations);
@@ -2262,7 +2375,18 @@ export function defineProvider<
2262
2375
  config.operations,
2263
2376
  journeyCoveredOperations,
2264
2377
  );
2265
- validateProviderHealthMonitor(config.id, config.healthMonitor);
2378
+ if (config.healthMonitor !== undefined && config.healthProbe !== undefined)
2379
+ throw new ValidationError(
2380
+ `Provider "${config.id}" declares both healthMonitor and healthProbe. They are aliases; declare exactly one.`,
2381
+ {
2382
+ fix: "Keep healthProbe (the new name) and delete the healthMonitor block.",
2383
+ },
2384
+ );
2385
+ validateProviderHealthMonitor(
2386
+ config.id,
2387
+ config.healthProbe ?? config.healthMonitor,
2388
+ config.healthProbe !== undefined ? "healthProbe" : "healthMonitor",
2389
+ );
2266
2390
  validateOperationFixtures(config.id, config.operations);
2267
2391
  validateProviderProxy(config);
2268
2392
  validateProviderStt(config);
@@ -2295,7 +2419,10 @@ export function defineProvider<
2295
2419
  context: config.context,
2296
2420
  meta: config.meta,
2297
2421
  operations: config.operations,
2298
- healthMonitor: config.healthMonitor,
2422
+ // Transitional healthMonitor → healthProbe alias: mirror whichever field
2423
+ // was declared onto both so old and new consumers keep working.
2424
+ healthMonitor: config.healthMonitor ?? config.healthProbe,
2425
+ healthProbe: config.healthProbe ?? config.healthMonitor,
2299
2426
  healthJourneys: config.healthJourneys,
2300
2427
  };
2301
2428
  }