@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.1

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 (79) hide show
  1. package/AUTHORING.md +93 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +133 -28
  4. package/bin/apifuse-check.ts +78 -71
  5. package/bin/apifuse-create.ts +12 -0
  6. package/bin/apifuse-dev.ts +24 -61
  7. package/bin/apifuse-pack-check.ts +87 -0
  8. package/bin/apifuse-pack-smoke.ts +122 -0
  9. package/bin/apifuse-perf.ts +33 -32
  10. package/bin/apifuse-record.ts +17 -7
  11. package/bin/apifuse-test.ts +6 -4
  12. package/bin/apifuse.ts +36 -35
  13. package/package.json +29 -9
  14. package/src/ceremonies/index.ts +768 -0
  15. package/src/cli/commands.ts +87 -0
  16. package/src/cli/create.ts +845 -0
  17. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  18. package/src/cli/templates/provider/README.md.tpl +41 -0
  19. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  20. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  21. package/src/cli/templates/provider/index.ts.tpl +58 -0
  22. package/src/cli/templates/provider/start.ts.tpl +5 -0
  23. package/src/config/loader.ts +61 -1
  24. package/src/define.ts +565 -41
  25. package/src/dev.ts +2 -6
  26. package/src/errors.ts +42 -0
  27. package/src/index.ts +44 -38
  28. package/src/lint.ts +574 -0
  29. package/src/provider.ts +13 -0
  30. package/src/runtime/auth-flow.ts +67 -0
  31. package/src/runtime/credential.ts +95 -0
  32. package/src/runtime/env.ts +13 -0
  33. package/src/runtime/executor.ts +13 -14
  34. package/src/runtime/http.ts +36 -12
  35. package/src/runtime/insights.ts +3 -3
  36. package/src/runtime/key-derivation.ts +122 -0
  37. package/src/runtime/keyring.ts +148 -0
  38. package/src/runtime/namespace.ts +33 -0
  39. package/src/runtime/prevalidate.ts +252 -0
  40. package/src/runtime/tls.ts +41 -17
  41. package/src/runtime/waterfall.ts +0 -1
  42. package/src/schema.ts +77 -0
  43. package/src/serve.ts +1 -664
  44. package/src/server/index.ts +22 -0
  45. package/src/server/serve.ts +624 -0
  46. package/src/server/types.ts +78 -0
  47. package/src/stealth/profiles.ts +10 -93
  48. package/src/testing/run.ts +391 -32
  49. package/src/types.ts +390 -41
  50. package/bin/apifuse-init.ts +0 -387
  51. package/src/__tests__/auth.test.ts +0 -396
  52. package/src/__tests__/browser-auth.test.ts +0 -180
  53. package/src/__tests__/browser.test.ts +0 -632
  54. package/src/__tests__/define.test.ts +0 -225
  55. package/src/__tests__/errors.test.ts +0 -69
  56. package/src/__tests__/executor.test.ts +0 -214
  57. package/src/__tests__/http.test.ts +0 -238
  58. package/src/__tests__/insights.test.ts +0 -210
  59. package/src/__tests__/instrumentation.test.ts +0 -290
  60. package/src/__tests__/otlp.test.ts +0 -141
  61. package/src/__tests__/perf.test.ts +0 -60
  62. package/src/__tests__/providers-yaml.test.ts +0 -135
  63. package/src/__tests__/proxy.test.ts +0 -359
  64. package/src/__tests__/recipes.test.ts +0 -36
  65. package/src/__tests__/serve.test.ts +0 -233
  66. package/src/__tests__/session.test.ts +0 -231
  67. package/src/__tests__/state.test.ts +0 -100
  68. package/src/__tests__/stealth.test.ts +0 -57
  69. package/src/__tests__/testing.test.ts +0 -97
  70. package/src/__tests__/tls.test.ts +0 -345
  71. package/src/__tests__/types.test.ts +0 -142
  72. package/src/__tests__/utils.test.ts +0 -62
  73. package/src/__tests__/waterfall.test.ts +0 -270
  74. package/src/config/providers-yaml.ts +0 -370
  75. package/src/index.test.ts +0 -1
  76. package/src/protocol.ts +0 -183
  77. package/src/runtime/auth.ts +0 -245
  78. package/src/runtime/session.ts +0 -573
  79. package/src/runtime/state.ts +0 -124
package/src/define.ts CHANGED
@@ -1,40 +1,563 @@
1
- import type { ZodType } from "zod";
2
1
  import { ProviderError, ValidationError } from "./errors";
2
+ import { safeParseSchemaSync } from "./schema";
3
3
  import type {
4
4
  AuthConfig,
5
5
  BrowserEngine,
6
+ ContextDeclaration,
7
+ CredentialDeclaration,
8
+ HealthCheckCase,
9
+ HealthCheckSuite,
10
+ HealthCheckUnsupported,
11
+ InferSchemaOutput,
6
12
  OperationDefinition,
13
+ ProbeInterval,
7
14
  ProviderDefinition,
15
+ ProviderHealthMonitorConfig,
16
+ ProviderReviewed,
17
+ ProviderSecretDeclaration,
18
+ SchemaLike,
8
19
  StealthPlatform,
9
20
  } from "./types";
21
+ import {
22
+ OPERATION_TIMEOUT_MS_MAX,
23
+ OPERATION_TIMEOUT_MS_MIN,
24
+ PROBE_INTERVALS,
25
+ } from "./types";
10
26
 
11
27
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
28
+ const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
29
+ const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
30
+ const VALID_AUTH_MODES = [
31
+ "none",
32
+ "platform-managed",
33
+ "credentials",
34
+ "oauth2",
35
+ ] as const;
36
+ const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
12
37
 
13
- type ProviderOperation = OperationDefinition<ZodType, ZodType>;
38
+ type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
39
+ type OperationConfig<
40
+ TInput extends SchemaLike,
41
+ TOutput extends SchemaLike,
42
+ > = Omit<OperationDefinition<TInput, TOutput>, "handler"> & {
43
+ handler(
44
+ ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
45
+ input: InferSchemaOutput<TInput>,
46
+ ): Promise<InferSchemaOutput<TOutput>>;
47
+ };
48
+ type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
49
+ {
50
+ [K in keyof TOperations]: TOperations[K] extends OperationDefinition<
51
+ infer TInput,
52
+ infer TOutput
53
+ >
54
+ ? OperationConfig<TInput, TOutput>
55
+ : never;
56
+ };
14
57
 
15
58
  export interface ProviderConfig<
16
59
  TOperations extends Record<string, ProviderOperation>,
17
60
  > {
18
61
  id: string;
19
62
  version: string;
20
- runtime: "standard" | "browser";
21
- stealth?: {
22
- profile: string;
23
- platform: StealthPlatform;
24
- };
63
+ runtime: "standard" | "shared" | "browser";
64
+ allowedHosts?: string[];
65
+ stealth?: { profile: string; platform: StealthPlatform };
25
66
  proxy?: boolean;
26
- browser?: {
27
- engine: BrowserEngine;
28
- };
67
+ browser?: { engine: BrowserEngine };
29
68
  auth?: AuthConfig;
69
+ reviewed?: ProviderReviewed;
70
+ secrets?: ProviderSecretDeclaration[];
71
+ credential?: CredentialDeclaration;
72
+ context?: ContextDeclaration;
30
73
  meta: {
31
74
  displayName: string;
32
75
  description?: string;
33
76
  category: string;
34
77
  tags?: string[];
35
78
  icon?: string;
79
+ docTitle?: string;
80
+ docDescription?: string;
81
+ docSummary?: string;
82
+ normalizationNotes?: string[];
83
+ environment?: "staging";
84
+ purpose?: string;
36
85
  };
37
- operations: TOperations;
86
+ operations: OperationMapConfig<TOperations>;
87
+ healthMonitor?: ProviderHealthMonitorConfig;
88
+ }
89
+
90
+ /** Define one provider operation with schema-driven handler inference. */
91
+ export function defineOperation<
92
+ TInput extends SchemaLike,
93
+ TOutput extends SchemaLike,
94
+ >(
95
+ operation: OperationConfig<TInput, TOutput>,
96
+ ): OperationDefinition<TInput, TOutput> {
97
+ return operation;
98
+ }
99
+
100
+ function assertObjectConfig(
101
+ value: unknown,
102
+ ): asserts value is Record<string, unknown> {
103
+ if (!value || typeof value !== "object") {
104
+ throw new ProviderError(
105
+ "defineProvider config must be an object. Offending field: config",
106
+ {
107
+ fix: "Pass defineProvider({ id, version, runtime, meta, operations })",
108
+ },
109
+ );
110
+ }
111
+ }
112
+ function assertRequiredField(
113
+ config: Record<string, unknown>,
114
+ field: string,
115
+ providerId?: string,
116
+ ): void {
117
+ if (!Object.hasOwn(config, field) || config[field] === undefined) {
118
+ const prefix = providerId ? `Provider "${providerId}"` : "Provider config";
119
+ throw new ProviderError(`${prefix} is missing required field "${field}"`, {
120
+ fix: `Add ${field} to defineProvider({ ... })`,
121
+ });
122
+ }
123
+ }
124
+ function assertLiteralField<TValue extends string>(
125
+ value: string,
126
+ field: string,
127
+ validValues: readonly TValue[],
128
+ providerId: string,
129
+ ): asserts value is TValue {
130
+ if (!validValues.some((validValue) => validValue === value)) {
131
+ throw new ProviderError(
132
+ `Provider "${providerId}" has invalid ${field}: "${value}". Expected one of: ${validValues.join(", ")}`,
133
+ {
134
+ fix: `Set ${field} to one of ${validValues.map((item) => `"${item}"`).join(", ")}`,
135
+ },
136
+ );
137
+ }
138
+ }
139
+ function validateProviderShape(config: unknown): void {
140
+ assertObjectConfig(config);
141
+ assertRequiredField(config, "id");
142
+ assertRequiredField(config, "version", String(config.id));
143
+ assertRequiredField(config, "runtime", String(config.id));
144
+ assertRequiredField(config, "meta", String(config.id));
145
+ assertRequiredField(config, "operations", String(config.id));
146
+ if (typeof config.runtime === "string")
147
+ assertLiteralField(
148
+ config.runtime,
149
+ "runtime",
150
+ VALID_RUNTIMES,
151
+ String(config.id),
152
+ );
153
+ const auth = config.auth;
154
+ if (
155
+ auth &&
156
+ typeof auth === "object" &&
157
+ "mode" in auth &&
158
+ typeof auth.mode === "string"
159
+ )
160
+ assertLiteralField(
161
+ auth.mode,
162
+ "auth.mode",
163
+ VALID_AUTH_MODES,
164
+ String(config.id),
165
+ );
166
+ }
167
+ function validateOperationIds(
168
+ providerId: string,
169
+ operations: Record<string, ProviderOperation>,
170
+ ): void {
171
+ for (const operationName of Object.keys(operations)) {
172
+ if (!OPERATION_ID_REGEX.test(operationName))
173
+ throw new ProviderError(
174
+ `Provider "${providerId}" has invalid operations.${operationName}: operation ids must be URL-safe and cannot contain slashes.`,
175
+ {
176
+ fix: `Rename operations.${operationName} to a lowercase URL-safe id such as "search-items"`,
177
+ },
178
+ );
179
+ if (RESERVED_OPERATION_IDS.has(operationName))
180
+ throw new ProviderError(
181
+ `Provider "${providerId}" operation "${operationName}" conflicts with a reserved server path.`,
182
+ {
183
+ fix: `Rename operations.${operationName} to avoid /${operationName}`,
184
+ },
185
+ );
186
+ }
187
+ }
188
+ function validateOperationAnnotations(
189
+ providerId: string,
190
+ operations: Record<string, ProviderOperation>,
191
+ ): void {
192
+ for (const [operationName, operation] of Object.entries(operations)) {
193
+ const annotations = operation.annotations;
194
+ if (!annotations) continue;
195
+ const timeoutMs = annotations.timeoutMs;
196
+ if (timeoutMs === undefined) continue;
197
+ const field = `operations.${operationName}.annotations.timeoutMs`;
198
+ if (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs))
199
+ throw new ValidationError(
200
+ `Provider "${providerId}" has invalid ${field}: must be an integer number of milliseconds.`,
201
+ {
202
+ fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] (milliseconds).`,
203
+ },
204
+ );
205
+ if (
206
+ timeoutMs < OPERATION_TIMEOUT_MS_MIN ||
207
+ timeoutMs > OPERATION_TIMEOUT_MS_MAX
208
+ )
209
+ throw new ValidationError(
210
+ `Provider "${providerId}" has invalid ${field}: ${timeoutMs} is outside [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms.`,
211
+ {
212
+ fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms (the upper bound stays below the gateway/ALB ceiling).`,
213
+ },
214
+ );
215
+ }
216
+ }
217
+
218
+ const HEALTH_CHECK_SUITE_FIELDS = new Set([
219
+ "interval",
220
+ "timeoutMs",
221
+ "cases",
222
+ "requiresConnection",
223
+ ]);
224
+ const HEALTH_CHECK_CASE_FIELDS = new Set([
225
+ "name",
226
+ "description",
227
+ "input",
228
+ "assertions",
229
+ "degradedThresholdMs",
230
+ "expectedStatus",
231
+ "enabled",
232
+ ]);
233
+ const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
234
+ const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
235
+ "requiredSecrets",
236
+ "probeOverrides",
237
+ "serviceAccount",
238
+ ]);
239
+ const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set(["interval"]);
240
+
241
+ function levenshtein(a: string, b: string): number {
242
+ const m = a.length;
243
+ const n = b.length;
244
+ if (m === 0) return n;
245
+ if (n === 0) return m;
246
+ const prev = new Array<number>(n + 1);
247
+ const curr = new Array<number>(n + 1);
248
+ for (let j = 0; j <= n; j++) prev[j] = j;
249
+ for (let i = 1; i <= m; i++) {
250
+ curr[0] = i;
251
+ for (let j = 1; j <= n; j++) {
252
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
253
+ const deletion = (prev[j] ?? 0) + 1;
254
+ const insertion = (curr[j - 1] ?? 0) + 1;
255
+ const substitution = (prev[j - 1] ?? 0) + cost;
256
+ curr[j] = Math.min(deletion, insertion, substitution);
257
+ }
258
+ for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
259
+ }
260
+ return prev[n] ?? 0;
261
+ }
262
+
263
+ function suggestField(
264
+ unknown: string,
265
+ candidates: ReadonlySet<string>,
266
+ ): string | undefined {
267
+ let best: string | undefined;
268
+ let bestDist = 3;
269
+ for (const candidate of candidates) {
270
+ const dist = levenshtein(unknown, candidate);
271
+ if (dist < bestDist) {
272
+ bestDist = dist;
273
+ best = candidate;
274
+ }
275
+ }
276
+ return best;
277
+ }
278
+
279
+ function rejectUnknownFields(
280
+ value: Record<string, unknown>,
281
+ allowed: ReadonlySet<string>,
282
+ fieldPath: string,
283
+ ): void {
284
+ for (const key of Object.keys(value)) {
285
+ if (allowed.has(key)) continue;
286
+ const hint = suggestField(key, allowed);
287
+ throw new ValidationError(
288
+ hint
289
+ ? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
290
+ : `Unknown field "${key}" on ${fieldPath}.`,
291
+ { fix: `Remove ${fieldPath}.${key} or rename it.` },
292
+ );
293
+ }
294
+ }
295
+
296
+ function validateProviderHealthMonitor(
297
+ providerId: string,
298
+ healthMonitor: unknown,
299
+ ): void {
300
+ if (healthMonitor === undefined) return;
301
+ if (
302
+ !healthMonitor ||
303
+ typeof healthMonitor !== "object" ||
304
+ Array.isArray(healthMonitor)
305
+ )
306
+ throw new ValidationError(
307
+ `Provider "${providerId}" has invalid healthMonitor: must be an object.`,
308
+ {
309
+ fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
310
+ },
311
+ );
312
+ const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
313
+ rejectUnknownFields(
314
+ healthMonitorRecord,
315
+ PROVIDER_HEALTH_MONITOR_FIELDS,
316
+ "healthMonitor",
317
+ );
318
+ const requiredSecrets = healthMonitorRecord.requiredSecrets;
319
+ if (requiredSecrets !== undefined) {
320
+ if (!Array.isArray(requiredSecrets))
321
+ throw new ValidationError(
322
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
323
+ );
324
+ for (const [index, secret] of requiredSecrets.entries()) {
325
+ if (typeof secret !== "string" || secret.length === 0)
326
+ throw new ValidationError(
327
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
328
+ );
329
+ }
330
+ }
331
+ const probeOverrides = healthMonitorRecord.probeOverrides;
332
+ if (probeOverrides !== undefined) {
333
+ if (
334
+ !probeOverrides ||
335
+ typeof probeOverrides !== "object" ||
336
+ Array.isArray(probeOverrides)
337
+ )
338
+ throw new ValidationError(
339
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
340
+ );
341
+ for (const [probeId, override] of Object.entries(probeOverrides)) {
342
+ if (probeId.length === 0)
343
+ throw new ValidationError(
344
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
345
+ );
346
+ if (!override || typeof override !== "object" || Array.isArray(override))
347
+ throw new ValidationError(
348
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
349
+ );
350
+ const overrideRecord = Object.fromEntries(Object.entries(override));
351
+ rejectUnknownFields(
352
+ overrideRecord,
353
+ PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
354
+ `healthMonitor.probeOverrides["${probeId}"]`,
355
+ );
356
+ const interval = overrideRecord.interval;
357
+ const validProbeIntervals: readonly string[] = PROBE_INTERVALS;
358
+ if (
359
+ interval !== undefined &&
360
+ (typeof interval !== "string" ||
361
+ !validProbeIntervals.includes(interval))
362
+ )
363
+ throw new ValidationError(
364
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be one of ${PROBE_INTERVALS.join(", ")}.`,
365
+ );
366
+ }
367
+ }
368
+ const serviceAccount = healthMonitorRecord.serviceAccount;
369
+ if (
370
+ serviceAccount !== undefined &&
371
+ (typeof serviceAccount !== "string" || serviceAccount.length === 0)
372
+ )
373
+ throw new ValidationError(
374
+ `Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
375
+ );
376
+ }
377
+
378
+ function validateHealthCheckCase(
379
+ providerId: string,
380
+ operationName: string,
381
+ caseValue: unknown,
382
+ caseIndex: number,
383
+ ): void {
384
+ const fieldPath = `operations.${operationName}.healthCheck.cases[${caseIndex}]`;
385
+ if (!caseValue || typeof caseValue !== "object" || Array.isArray(caseValue))
386
+ throw new ValidationError(
387
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
388
+ );
389
+ rejectUnknownFields(
390
+ caseValue as Record<string, unknown>,
391
+ HEALTH_CHECK_CASE_FIELDS,
392
+ fieldPath,
393
+ );
394
+ const c = caseValue as HealthCheckCase;
395
+ if (typeof c.name !== "string" || c.name.length === 0)
396
+ throw new ValidationError(
397
+ `Provider "${providerId}" ${fieldPath}.name must be a non-empty string.`,
398
+ );
399
+ if (typeof c.assertions !== "function")
400
+ throw new ValidationError(
401
+ `Provider "${providerId}" ${fieldPath}.assertions must be a function.`,
402
+ {
403
+ fix: `Set ${fieldPath}.assertions to (ctx) => { ... } that throws on failure.`,
404
+ },
405
+ );
406
+ if (
407
+ c.degradedThresholdMs !== undefined &&
408
+ (typeof c.degradedThresholdMs !== "number" ||
409
+ !Number.isFinite(c.degradedThresholdMs) ||
410
+ c.degradedThresholdMs <= 0)
411
+ )
412
+ throw new ValidationError(
413
+ `Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be a positive number.`,
414
+ );
415
+ if (
416
+ c.expectedStatus !== undefined &&
417
+ c.expectedStatus !== "ok" &&
418
+ c.expectedStatus !== "degraded"
419
+ )
420
+ throw new ValidationError(
421
+ `Provider "${providerId}" ${fieldPath}.expectedStatus must be "ok" or "degraded".`,
422
+ );
423
+ if (c.enabled !== undefined && typeof c.enabled !== "function")
424
+ throw new ValidationError(
425
+ `Provider "${providerId}" ${fieldPath}.enabled must be a function returning boolean.`,
426
+ );
427
+ }
428
+
429
+ function validateHealthCheckSuite(
430
+ providerId: string,
431
+ operationName: string,
432
+ suite: unknown,
433
+ ): void {
434
+ const fieldPath = `operations.${operationName}.healthCheck`;
435
+ if (!suite || typeof suite !== "object" || Array.isArray(suite))
436
+ throw new ValidationError(
437
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
438
+ );
439
+ rejectUnknownFields(
440
+ suite as Record<string, unknown>,
441
+ HEALTH_CHECK_SUITE_FIELDS,
442
+ fieldPath,
443
+ );
444
+ const s = suite as HealthCheckSuite;
445
+ if (
446
+ typeof s.interval !== "string" ||
447
+ !PROBE_INTERVALS.includes(s.interval as ProbeInterval)
448
+ )
449
+ throw new ValidationError(
450
+ `Provider "${providerId}" ${fieldPath}.interval must be one of ${PROBE_INTERVALS.join(", ")}.`,
451
+ {
452
+ fix: `Set ${fieldPath}.interval to a supported probe interval.`,
453
+ },
454
+ );
455
+ if (s.timeoutMs !== undefined) {
456
+ if (
457
+ typeof s.timeoutMs !== "number" ||
458
+ !Number.isInteger(s.timeoutMs) ||
459
+ s.timeoutMs <= 0
460
+ )
461
+ throw new ValidationError(
462
+ `Provider "${providerId}" ${fieldPath}.timeoutMs must be a positive integer (ms).`,
463
+ );
464
+ }
465
+ if (
466
+ s.requiresConnection !== undefined &&
467
+ typeof s.requiresConnection !== "boolean"
468
+ )
469
+ throw new ValidationError(
470
+ `Provider "${providerId}" ${fieldPath}.requiresConnection must be a boolean.`,
471
+ );
472
+ if (!Array.isArray(s.cases) || s.cases.length === 0)
473
+ throw new ValidationError(
474
+ `Provider "${providerId}" ${fieldPath}.cases must be a non-empty array.`,
475
+ {
476
+ fix: `Add at least one HealthCheckCase to ${fieldPath}.cases.`,
477
+ },
478
+ );
479
+ const seenNames = new Set<string>();
480
+ for (const [index, caseValue] of s.cases.entries()) {
481
+ validateHealthCheckCase(providerId, operationName, caseValue, index);
482
+ const name = (caseValue as HealthCheckCase).name;
483
+ if (seenNames.has(name))
484
+ throw new ValidationError(
485
+ `Provider "${providerId}" ${fieldPath}.cases has duplicate case name "${name}".`,
486
+ {
487
+ fix: `Rename one of the duplicate cases to be unique within the suite.`,
488
+ },
489
+ );
490
+ seenNames.add(name);
491
+ }
492
+ }
493
+
494
+ function validateHealthCheckUnsupported(
495
+ providerId: string,
496
+ operationName: string,
497
+ unsupported: unknown,
498
+ ): void {
499
+ const fieldPath = `operations.${operationName}.healthCheckUnsupported`;
500
+ if (
501
+ !unsupported ||
502
+ typeof unsupported !== "object" ||
503
+ Array.isArray(unsupported)
504
+ )
505
+ throw new ValidationError(
506
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
507
+ );
508
+ rejectUnknownFields(
509
+ unsupported as Record<string, unknown>,
510
+ HEALTH_CHECK_UNSUPPORTED_FIELDS,
511
+ fieldPath,
512
+ );
513
+ const u = unsupported as HealthCheckUnsupported;
514
+ if (typeof u.reason !== "string" || u.reason.trim().length === 0)
515
+ throw new ValidationError(
516
+ `Provider "${providerId}" ${fieldPath}.reason must be a non-empty string.`,
517
+ {
518
+ fix: `Document why the operation cannot be probed (e.g., "Destructive mutation; cannot probe in production").`,
519
+ },
520
+ );
521
+ if (u.trackedIn !== undefined && typeof u.trackedIn !== "string")
522
+ throw new ValidationError(
523
+ `Provider "${providerId}" ${fieldPath}.trackedIn must be a string when present.`,
524
+ );
525
+ }
526
+
527
+ function validateOperationHealthChecks(
528
+ providerId: string,
529
+ operations: Record<string, ProviderOperation>,
530
+ ): void {
531
+ for (const [operationName, operation] of Object.entries(operations)) {
532
+ const hasCheck = operation.healthCheck !== undefined;
533
+ const hasUnsupported = operation.healthCheckUnsupported !== undefined;
534
+ if (hasCheck && hasUnsupported)
535
+ throw new ValidationError(
536
+ `Provider "${providerId}" operation "${operationName}" declares both healthCheck and healthCheckUnsupported. Choose exactly one.`,
537
+ {
538
+ fix: `Remove either operations.${operationName}.healthCheck or operations.${operationName}.healthCheckUnsupported.`,
539
+ },
540
+ );
541
+ if (hasCheck)
542
+ validateHealthCheckSuite(
543
+ providerId,
544
+ operationName,
545
+ operation.healthCheck,
546
+ );
547
+ if (hasUnsupported)
548
+ validateHealthCheckUnsupported(
549
+ providerId,
550
+ operationName,
551
+ operation.healthCheckUnsupported,
552
+ );
553
+ if (!hasCheck && !hasUnsupported)
554
+ throw new ValidationError(
555
+ `Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
556
+ {
557
+ fix: `Add \`healthCheck: { interval, cases: [...] }\` or \`healthCheckUnsupported: { reason: "..." }\` to operations.${operationName}.`,
558
+ },
559
+ );
560
+ }
38
561
  }
39
562
 
40
563
  function validateOperationFixtures(
@@ -42,19 +565,20 @@ function validateOperationFixtures(
42
565
  operations: Record<string, ProviderOperation>,
43
566
  ): void {
44
567
  for (const [operationName, operation] of Object.entries(operations)) {
45
- if (typeof operation.handler !== "function") {
568
+ if (typeof operation.handler !== "function")
46
569
  throw new ValidationError(
47
570
  `Operation handler must be defined for provider "${providerId}" operation "${operationName}"`,
48
571
  {
49
572
  fix: `Add operations.${operationName}.handler as an async function with signature (ctx, input) => Promise<output>`,
50
573
  },
51
574
  );
52
- }
53
-
54
575
  if (operation.fixtures?.request !== undefined) {
55
- const result = operation.input.safeParse(operation.fixtures.request);
56
-
57
- if (!result.success) {
576
+ const result = safeParseSchemaSync(
577
+ operation.input,
578
+ operation.fixtures.request,
579
+ `operations.${operationName}.fixtures.request`,
580
+ );
581
+ if (!result.success)
58
582
  throw new ValidationError(
59
583
  `Fixture request does not match input schema for provider "${providerId}" operation "${operationName}"`,
60
584
  {
@@ -62,13 +586,14 @@ function validateOperationFixtures(
62
586
  zodError: result.error,
63
587
  },
64
588
  );
65
- }
66
589
  }
67
-
68
590
  if (operation.fixtures?.response !== undefined) {
69
- const result = operation.output.safeParse(operation.fixtures.response);
70
-
71
- if (!result.success) {
591
+ const result = safeParseSchemaSync(
592
+ operation.output,
593
+ operation.fixtures.response,
594
+ `operations.${operationName}.fixtures.response`,
595
+ );
596
+ if (!result.success)
72
597
  throw new ValidationError(
73
598
  `Fixture response does not match output schema for provider "${providerId}" operation "${operationName}"`,
74
599
  {
@@ -76,7 +601,6 @@ function validateOperationFixtures(
76
601
  zodError: result.error,
77
602
  },
78
603
  );
79
- }
80
604
  }
81
605
  }
82
606
  }
@@ -85,53 +609,53 @@ export function defineProvider<
85
609
  TOperations extends Record<string, ProviderOperation>,
86
610
  >(
87
611
  config: ProviderConfig<TOperations>,
88
- ): ProviderDefinition & { operations: TOperations } {
89
- if (!CONNECTOR_ID_REGEX.test(config.id)) {
612
+ ): ProviderDefinition & { operations: OperationMapConfig<TOperations> } {
613
+ validateProviderShape(config);
614
+ if (!CONNECTOR_ID_REGEX.test(config.id))
90
615
  throw new ProviderError(`Invalid provider id: "${config.id}"`, {
91
- fix: 'Use lowercase alphanumeric with dashes, e.g., "coingecko-prices"',
616
+ fix: 'Use lowercase alphanumeric with dashes, e.g., "airkorea-realtime"',
92
617
  });
93
- }
94
-
95
- if (Object.keys(config.operations).length === 0) {
618
+ if (Object.keys(config.operations).length === 0)
96
619
  throw new ProviderError(
97
620
  `Provider "${config.id}" must define at least one operation`,
98
621
  { fix: "Add at least one operation to the operations object" },
99
622
  );
100
- }
101
-
623
+ validateOperationIds(config.id, config.operations);
624
+ validateOperationAnnotations(config.id, config.operations);
625
+ validateOperationHealthChecks(config.id, config.operations);
626
+ validateProviderHealthMonitor(config.id, config.healthMonitor);
102
627
  validateOperationFixtures(config.id, config.operations);
103
-
104
- if (config.runtime === "browser" && !config.browser) {
628
+ if (config.runtime === "browser" && !config.browser)
105
629
  throw new ProviderError(
106
630
  `Provider "${config.id}" must define browser.engine when runtime is "browser"`,
107
631
  {
108
632
  fix: 'Add browser: { engine: "nodriver" } or another supported engine',
109
633
  },
110
634
  );
111
- }
112
-
113
- if (config.browser && config.runtime !== "browser") {
635
+ if (config.browser && config.runtime !== "browser")
114
636
  throw new ProviderError(
115
637
  `Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
116
638
  { fix: 'Set runtime: "browser" or remove the browser config' },
117
639
  );
118
- }
119
-
120
- if (config.proxy && !config.stealth) {
640
+ if (config.proxy && !config.stealth)
121
641
  console.warn(
122
642
  `[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
123
643
  );
124
- }
125
-
126
644
  return {
127
645
  id: config.id,
128
646
  version: config.version,
129
647
  runtime: config.runtime,
648
+ allowedHosts: config.allowedHosts,
130
649
  stealth: config.stealth,
131
650
  proxy: config.proxy,
132
651
  browser: config.browser,
133
652
  auth: config.auth,
653
+ reviewed: config.reviewed,
654
+ secrets: config.secrets,
655
+ credential: config.credential,
656
+ context: config.context,
134
657
  meta: config.meta,
135
658
  operations: config.operations,
659
+ healthMonitor: config.healthMonitor,
136
660
  };
137
661
  }