@apifuse/provider-sdk 2.1.0-beta.12 → 2.1.0-beta.14

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.
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>;
@@ -374,6 +353,14 @@ function validateProviderShape(config: unknown): void {
374
353
  VALID_AUTH_MODES,
375
354
  String(config.id),
376
355
  );
356
+ if (auth && typeof auth === "object" && "exchange" in auth) {
357
+ throw new ProviderError(
358
+ `Provider "${String(config.id)}" auth.exchange is not part of the Provider SDK auth contract`,
359
+ {
360
+ fix: "Use the single canonical auth interface: auth.flow. Gateway calls auth.flow.start/continue/poll/abort/refresh only and persists complete turn data.credential as-is, so put login/token/session exchange inside auth.flow.continue.",
361
+ },
362
+ );
363
+ }
377
364
  if (
378
365
  auth &&
379
366
  typeof auth === "object" &&
@@ -1116,6 +1103,7 @@ function validateOperationTransports(
1116
1103
 
1117
1104
  const HEALTH_CHECK_SUITE_FIELDS = new Set([
1118
1105
  "interval",
1106
+ "schedule",
1119
1107
  "timeoutMs",
1120
1108
  "degradedThresholdMs",
1121
1109
  "cases",
@@ -1463,6 +1451,35 @@ function validateHealthCheckSuite(
1463
1451
  fix: `Set ${fieldPath}.interval to a positive ms-style duration string.`,
1464
1452
  },
1465
1453
  );
1454
+ if (s.schedule !== undefined) {
1455
+ if (
1456
+ !s.schedule ||
1457
+ typeof s.schedule !== "object" ||
1458
+ Array.isArray(s.schedule)
1459
+ ) {
1460
+ throw new ValidationError(
1461
+ `Provider "${providerId}" ${fieldPath}.schedule must be an object.`,
1462
+ );
1463
+ }
1464
+ if (Reflect.get(s.schedule, "jitter") !== undefined) {
1465
+ throw new ValidationError(
1466
+ `Provider "${providerId}" ${fieldPath}.schedule.jitter is not supported for operation healthCheck schedules. Use schedule.randomize instead.`,
1467
+ );
1468
+ }
1469
+ rejectUnknownFields(
1470
+ s.schedule,
1471
+ new Set(["randomize"]),
1472
+ `${fieldPath}.schedule`,
1473
+ );
1474
+ const randomize = Reflect.get(s.schedule, "randomize");
1475
+ if (randomize !== undefined) {
1476
+ validateScheduleRandomization(
1477
+ randomize,
1478
+ `Provider "${providerId}" ${fieldPath}.schedule.randomize`,
1479
+ msDurationMs(s.interval),
1480
+ );
1481
+ }
1482
+ }
1466
1483
  if (s.timeoutMs !== undefined) {
1467
1484
  assertBoundedIntegerMs(
1468
1485
  s.timeoutMs,
@@ -1561,7 +1578,12 @@ const HEALTH_JOURNEY_FIELDS = new Set([
1561
1578
  "steps",
1562
1579
  "run",
1563
1580
  ]);
1564
- const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter"]);
1581
+ const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set([
1582
+ "kind",
1583
+ "interval",
1584
+ "jitter",
1585
+ "randomize",
1586
+ ]);
1565
1587
  const HEALTH_JOURNEY_STEP_FIELDS = new Set([
1566
1588
  "id",
1567
1589
  "description",
@@ -1734,6 +1756,54 @@ function isoDurationMs(value: string): number {
1734
1756
  );
1735
1757
  }
1736
1758
 
1759
+ function scheduleRandomizationMs(
1760
+ randomize: unknown,
1761
+ fieldPath: string,
1762
+ ): number {
1763
+ const mode = Reflect.get(randomize as object, "mode");
1764
+ switch (mode) {
1765
+ case "centered": {
1766
+ const maxOffset = Reflect.get(randomize as object, "maxOffset");
1767
+ assertIsoDuration(maxOffset, `${fieldPath}.maxOffset`);
1768
+ return isoDurationMs(maxOffset);
1769
+ }
1770
+ case "delayed": {
1771
+ const maxDelay = Reflect.get(randomize as object, "maxDelay");
1772
+ assertIsoDuration(maxDelay, `${fieldPath}.maxDelay`);
1773
+ return isoDurationMs(maxDelay);
1774
+ }
1775
+ default:
1776
+ throw new ValidationError(
1777
+ `${fieldPath}.mode must be "centered" or "delayed".`,
1778
+ );
1779
+ }
1780
+ }
1781
+
1782
+ function validateScheduleRandomization(
1783
+ randomize: unknown,
1784
+ fieldPath: string,
1785
+ intervalMs: number,
1786
+ ): void {
1787
+ if (!randomize || typeof randomize !== "object" || Array.isArray(randomize)) {
1788
+ throw new ValidationError(`${fieldPath} must be an object.`);
1789
+ }
1790
+ const mode = Reflect.get(randomize, "mode");
1791
+ const allowedFields =
1792
+ mode === "centered"
1793
+ ? new Set(["mode", "maxOffset"])
1794
+ : new Set(["mode", "maxDelay"]);
1795
+ rejectUnknownFields(randomize, allowedFields, fieldPath);
1796
+ const offsetMs = scheduleRandomizationMs(randomize, fieldPath);
1797
+ if (offsetMs <= 0) {
1798
+ throw new ValidationError(`${fieldPath} duration must be positive.`);
1799
+ }
1800
+ if (offsetMs >= intervalMs) {
1801
+ throw new ValidationError(
1802
+ `${fieldPath} duration must be shorter than schedule interval.`,
1803
+ );
1804
+ }
1805
+ }
1806
+
1737
1807
  function assertIsoCountry(
1738
1808
  value: unknown,
1739
1809
  fieldPath: string,
@@ -1749,13 +1819,21 @@ function normalizeIntervalDuration(input: string): string {
1749
1819
  const trimmed = input.trim();
1750
1820
  const shorthand = /^(\d+)(s|m|h|d)$/i.exec(trimmed);
1751
1821
  if (shorthand) {
1752
- const amount = Number(shorthand[1]);
1822
+ const durationMs = msDurationMs(trimmed);
1823
+ const unit = shorthand[2]?.toLowerCase();
1824
+ const amount =
1825
+ unit === "s"
1826
+ ? durationMs / 1_000
1827
+ : unit === "m"
1828
+ ? durationMs / 60_000
1829
+ : unit === "h"
1830
+ ? durationMs / 3_600_000
1831
+ : durationMs / 86_400_000;
1753
1832
  if (!Number.isInteger(amount) || amount <= 0) {
1754
1833
  throw new ValidationError(
1755
1834
  `Journey schedule interval must be a positive duration.`,
1756
1835
  );
1757
1836
  }
1758
- const unit = shorthand[2]?.toLowerCase();
1759
1837
  if (unit === "s") return `PT${amount}S`;
1760
1838
  if (unit === "m") return `PT${amount}M`;
1761
1839
  if (unit === "h") return `PT${amount}H`;
@@ -1767,18 +1845,34 @@ function normalizeIntervalDuration(input: string): string {
1767
1845
 
1768
1846
  export function every(
1769
1847
  interval: string,
1770
- options: { jitter?: string } = {},
1848
+ options: { jitter?: string; randomize?: HealthScheduleRandomization } = {},
1771
1849
  ): HealthJourneySchedule {
1850
+ if (options.jitter !== undefined && options.randomize !== undefined) {
1851
+ throw new ValidationError(
1852
+ `Schedule cannot define both jitter and randomize. Use randomize instead.`,
1853
+ );
1854
+ }
1772
1855
  const schedule: HealthJourneySchedule = {
1773
1856
  kind: "interval",
1774
1857
  interval: normalizeIntervalDuration(interval),
1775
1858
  };
1859
+ if (options.randomize !== undefined) {
1860
+ schedule.randomize = options.randomize;
1861
+ }
1776
1862
  if (options.jitter !== undefined) {
1777
1863
  schedule.jitter = normalizeIntervalDuration(options.jitter);
1778
1864
  }
1779
1865
  return schedule;
1780
1866
  }
1781
1867
 
1868
+ export function centered(maxOffset: string): HealthScheduleRandomization {
1869
+ return { mode: "centered", maxOffset: normalizeIntervalDuration(maxOffset) };
1870
+ }
1871
+
1872
+ export function delayed(maxDelay: string): HealthScheduleRandomization {
1873
+ return { mode: "delayed", maxDelay: normalizeIntervalDuration(maxDelay) };
1874
+ }
1875
+
1782
1876
  function countCapturingGroups(pattern: RegExp): number {
1783
1877
  let count = 0;
1784
1878
  const source = pattern.source;
@@ -1993,15 +2087,29 @@ function validateHealthJourneySchedule(
1993
2087
  throw new ValidationError(
1994
2088
  `Provider "${providerId}" ${fieldPath}.kind must be "interval".`,
1995
2089
  );
1996
- assertIsoDuration(
1997
- Reflect.get(schedule, "interval"),
1998
- `Provider "${providerId}" ${fieldPath}.interval`,
1999
- );
2090
+ const interval = Reflect.get(schedule, "interval");
2091
+ assertIsoDuration(interval, `Provider "${providerId}" ${fieldPath}.interval`);
2092
+ const randomize = Reflect.get(schedule, "randomize");
2093
+ if (
2094
+ Reflect.get(schedule, "jitter") !== undefined &&
2095
+ randomize !== undefined
2096
+ ) {
2097
+ throw new ValidationError(
2098
+ `Provider "${providerId}" ${fieldPath} cannot define both jitter and randomize.`,
2099
+ );
2100
+ }
2000
2101
  if (Reflect.get(schedule, "jitter") !== undefined)
2001
2102
  assertIsoDuration(
2002
2103
  Reflect.get(schedule, "jitter"),
2003
2104
  `Provider "${providerId}" ${fieldPath}.jitter`,
2004
2105
  );
2106
+ if (randomize !== undefined) {
2107
+ validateScheduleRandomization(
2108
+ randomize,
2109
+ `Provider "${providerId}" ${fieldPath}.randomize`,
2110
+ isoDurationMs(interval),
2111
+ );
2112
+ }
2005
2113
  }
2006
2114
 
2007
2115
  function validateHealthJourneys(
@@ -2244,7 +2352,9 @@ export function defineProvider<
2244
2352
  if (Object.keys(config.operations).length === 0)
2245
2353
  throw new ProviderError(
2246
2354
  `Provider "${config.id}" must define at least one operation`,
2247
- { fix: "Add at least one operation to the operations object" },
2355
+ {
2356
+ fix: "Add at least one operation to the operations object",
2357
+ },
2248
2358
  );
2249
2359
  validateOperationIds(config.id, config.operations);
2250
2360
  validateOperationAnnotations(config.id, config.operations);
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // @apifuse/provider-sdk
2
2
 
3
+ export * from "./auth";
3
4
  export * from "./ceremonies";
4
5
  export * from "./choice-token";
5
6
  export type {
@@ -20,6 +21,8 @@ export {
20
21
  type ProviderContractSnapshot,
21
22
  } from "./contract";
22
23
  export {
24
+ centered,
25
+ delayed,
23
26
  defineHealthJourney,
24
27
  defineOperation,
25
28
  defineProvider,
@@ -122,6 +125,12 @@ export type {
122
125
  Bcp47Locale,
123
126
  BrowserEngine,
124
127
  BrowserOptions,
128
+ BrowserResourceBody,
129
+ BrowserResourceDecision,
130
+ BrowserResourceMethod,
131
+ BrowserResourcePolicy,
132
+ BrowserResourceRequest,
133
+ BrowserResourceRoute,
125
134
  ConnectionMode,
126
135
  ContextDeclaration,
127
136
  CookieJar,
@@ -145,6 +154,7 @@ export type {
145
154
  HealthJourneyRunContext,
146
155
  HealthJourneyRunResult,
147
156
  HealthJourneySchedule,
157
+ HealthScheduleRandomization,
148
158
  HealthJourneySmsContext,
149
159
  HealthJourneyStep,
150
160
  HttpClient,
package/src/lint.ts CHANGED
@@ -22,8 +22,12 @@ type ProviderAuthLike = {
22
22
  abort?: unknown;
23
23
  refresh?: unknown;
24
24
  };
25
+ exchange?: unknown;
25
26
  };
26
27
 
28
+ const AUTH_OPERATION_ID_PATTERN =
29
+ /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
30
+
27
31
  type ProviderContractMetaLike = {
28
32
  publicSchemaFieldNames?: "normalized";
29
33
  };
@@ -127,6 +131,10 @@ function lintReviewed(
127
131
  ];
128
132
  }
129
133
 
134
+ function isProviderAuthLike(value: unknown): value is ProviderAuthLike {
135
+ return !!value && typeof value === "object" && !Array.isArray(value);
136
+ }
137
+
130
138
  function hasReusableSecretKeys(keys: readonly string[] | undefined): boolean {
131
139
  if (!keys) {
132
140
  return false;
@@ -215,6 +223,15 @@ function lintAuthModel(provider: {
215
223
  });
216
224
  }
217
225
 
226
+ if (isProviderAuthLike(provider.auth) && "exchange" in provider.auth) {
227
+ diagnostics.push({
228
+ rule: "auth-exchange-unsupported",
229
+ level: "error",
230
+ field: "auth.exchange",
231
+ message: `${providerLabel} must not define auth.exchange. The Provider SDK has one auth interface: auth.flow. Gateway only calls auth.flow.start/continue/poll/abort/refresh and persists complete turn data.credential as-is; put login/token/session exchange inside auth.flow.continue.`,
232
+ });
233
+ }
234
+
218
235
  if (authMode === "credentials" && credentialKeys.length === 0) {
219
236
  diagnostics.push({
220
237
  rule: "credential-keys-required-when-credentials-mode",
@@ -1006,6 +1023,22 @@ export function lintProvider(
1006
1023
  ...lintSelfHostedBrowserPatterns(provider, options),
1007
1024
  ];
1008
1025
 
1026
+ if (provider.operations) {
1027
+ const authMode = provider.auth?.mode;
1028
+ if (authMode === "credentials" || authMode === "oauth2") {
1029
+ for (const operationKey of Object.keys(provider.operations)) {
1030
+ if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1031
+ diagnostics.push({
1032
+ rule: "auth-operation-unsupported",
1033
+ level: "error",
1034
+ field: `operations.${operationKey}`,
1035
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
1009
1042
  if (!provider.operations) {
1010
1043
  return diagnostics;
1011
1044
  }
package/src/provider.ts CHANGED
@@ -1,3 +1,22 @@
1
+ export {
2
+ AuthAbortError,
3
+ credentialsAuthChallenge,
4
+ createAuthFlowHelpers,
5
+ defineCredentialsAuth,
6
+ } from "./auth";
7
+ export type {
8
+ CredentialsAuthChallengeDefinition,
9
+ CredentialsAuthChallengeRequest,
10
+ CredentialsAuthCompleteResult,
11
+ CredentialsAuthCredential,
12
+ CredentialsAuthField,
13
+ CredentialsAuthFields,
14
+ CredentialsAuthFieldType,
15
+ CredentialsAuthInput,
16
+ CredentialsAuthLoginResult,
17
+ DefineCredentialsAuthOptions,
18
+ DefinedCredentialsAuth,
19
+ } from "./auth";
1
20
  export { createFormCeremony } from "./ceremonies";
2
21
  export {
3
22
  assertFreshProviderChoiceIssuedAt,
@@ -8,6 +27,8 @@ export {
8
27
  parseProviderChoiceToken,
9
28
  } from "./choice-token";
10
29
  export {
30
+ centered,
31
+ delayed,
11
32
  defineHealthJourney,
12
33
  defineOperation,
13
34
  defineProvider,
@@ -50,7 +71,12 @@ export {
50
71
  z,
51
72
  } from "./schema";
52
73
  export type {
74
+ AuthAbortData,
75
+ AuthAbortRetry,
76
+ AuthFlowTerminalContext,
53
77
  AuthMode,
78
+ AuthSafeData,
79
+ AuthSafeJson,
54
80
  FlowContext,
55
81
  HealthCheckAssertionContext,
56
82
  HealthCheckCase,
@@ -61,6 +87,7 @@ export type {
61
87
  HealthJourneyManualTriggerPolicy,
62
88
  HealthJourneyRunContext,
63
89
  HealthJourneyRunResult,
90
+ HealthScheduleRandomization,
64
91
  HttpRetryOptions,
65
92
  HttpRetrySummary,
66
93
  InferSchemaOutput,
@@ -1,4 +1,5 @@
1
1
  import { ContextAccessError } from "../errors";
2
+ import { createAuthFlowHelpers } from "../auth";
2
3
  import type {
3
4
  ContextScratchpad,
4
5
  EnvContext,
@@ -70,5 +71,6 @@ export function createFlowContext(options: {
70
71
  env: options.env,
71
72
  context: createScratchpad(options.allowedKeys, options.initialContext),
72
73
  stt: options.stt ?? createUnsupportedSttClient(),
74
+ auth: createAuthFlowHelpers(),
73
75
  };
74
76
  }