@apifuse/provider-sdk 2.1.0-beta.3 → 2.1.0-beta.4
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/AUTHORING.md +163 -8
- package/CHANGELOG.md +8 -1
- package/README.md +17 -16
- package/SUBMISSION.md +4 -4
- package/bin/apifuse-dev.ts +12 -5
- package/bin/apifuse-pack-check.ts +9 -2
- package/bin/apifuse-pack-smoke.ts +127 -6
- package/bin/apifuse-perf.ts +19 -15
- package/bin/apifuse-record.ts +41 -53
- package/bin/apifuse-submit-check.ts +179 -7
- package/bin/apifuse.ts +1 -1
- package/package.json +17 -8
- package/src/choice-token.ts +164 -0
- package/src/cli/commands.ts +1 -3
- package/src/cli/create.ts +159 -50
- package/src/cli/templates/provider/README.md.tpl +24 -7
- package/src/cli/templates/provider/dev.ts.tpl +1 -1
- package/src/cli/templates/provider/domain/README.md.tpl +3 -0
- package/src/cli/templates/provider/index.ts.tpl +5 -47
- package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
- package/src/cli/templates/provider/meta.ts.tpl +7 -0
- package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
- package/src/cli/templates/provider/operations/ping.ts.tpl +23 -0
- package/src/cli/templates/provider/schemas/ping.ts.tpl +16 -0
- package/src/cli/templates/provider/start.ts.tpl +1 -1
- package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
- package/src/config/loader.ts +1206 -9
- package/src/define.ts +1618 -104
- package/src/errors.ts +12 -0
- package/src/i18n/catalog.ts +121 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/keys.ts +64 -0
- package/src/index.ts +149 -8
- package/src/lint.ts +297 -42
- package/src/observability.ts +41 -0
- package/src/provider.ts +60 -3
- package/src/public-schema-field-lint.ts +237 -0
- package/src/runtime/auth-flow.ts +7 -0
- package/src/runtime/browser.ts +77 -21
- package/src/runtime/cache.ts +582 -0
- package/src/runtime/executor.ts +13 -1
- package/src/runtime/http.ts +939 -195
- package/src/runtime/insights.ts +11 -11
- package/src/runtime/instrumentation.ts +12 -4
- package/src/runtime/key-derivation.ts +1 -1
- package/src/runtime/keyring.ts +4 -3
- package/src/runtime/proxy-errors.ts +132 -0
- package/src/runtime/proxy-telemetry.ts +253 -0
- package/src/runtime/request-options.ts +66 -0
- package/src/runtime/state.ts +76 -0
- package/src/runtime/stealth.ts +1145 -0
- package/src/runtime/stt.ts +629 -0
- package/src/schema.ts +363 -1
- package/src/server/serve.ts +816 -58
- package/src/server/types.ts +35 -0
- package/src/stream.ts +210 -0
- package/src/testing/run.ts +17 -4
- package/src/types.ts +869 -50
- package/src/runtime/tls.ts +0 -434
- package/src/types/playwright-stealth.d.ts +0 -9
package/src/define.ts
CHANGED
|
@@ -8,22 +8,66 @@ import type {
|
|
|
8
8
|
HealthCheckCase,
|
|
9
9
|
HealthCheckSuite,
|
|
10
10
|
HealthCheckUnsupported,
|
|
11
|
+
HealthJourneyDefinition,
|
|
12
|
+
HealthJourneySchedule,
|
|
11
13
|
InferSchemaOutput,
|
|
12
14
|
OperationDefinition,
|
|
13
|
-
|
|
15
|
+
OperationHandlerResult,
|
|
16
|
+
OperationHttpStreamTransport,
|
|
17
|
+
OperationSseTransport,
|
|
18
|
+
OperationTransport,
|
|
19
|
+
OperationWebSocketTransport,
|
|
20
|
+
ProviderAccessConfig,
|
|
14
21
|
ProviderDefinition,
|
|
15
22
|
ProviderHealthMonitorConfig,
|
|
23
|
+
ProviderProxyConfig,
|
|
24
|
+
ProviderPublicProfile,
|
|
16
25
|
ProviderReviewed,
|
|
17
26
|
ProviderSecretDeclaration,
|
|
27
|
+
ProviderStreamEvent,
|
|
28
|
+
ProviderSttConfig,
|
|
18
29
|
SchemaLike,
|
|
30
|
+
SmsOtpMatcherDefinition,
|
|
19
31
|
StealthPlatform,
|
|
20
32
|
} from "./types";
|
|
21
33
|
import {
|
|
34
|
+
HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
|
|
35
|
+
HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
|
|
36
|
+
HEALTH_CHECK_TIMEOUT_MS_MAX,
|
|
37
|
+
HEALTH_CHECK_TIMEOUT_MS_MIN,
|
|
22
38
|
OPERATION_TIMEOUT_MS_MAX,
|
|
23
39
|
OPERATION_TIMEOUT_MS_MIN,
|
|
24
|
-
|
|
40
|
+
STREAM_CHUNK_BYTES_MAX,
|
|
41
|
+
STREAM_CHUNK_BYTES_MIN,
|
|
42
|
+
STREAM_HEARTBEAT_MS_MAX,
|
|
43
|
+
STREAM_HEARTBEAT_MS_MIN,
|
|
44
|
+
STREAM_IDLE_TIMEOUT_MS_MAX,
|
|
45
|
+
STREAM_IDLE_TIMEOUT_MS_MIN,
|
|
46
|
+
STREAM_MAX_DURATION_MS_MAX,
|
|
47
|
+
STREAM_MAX_DURATION_MS_MIN,
|
|
25
48
|
} from "./types";
|
|
26
49
|
|
|
50
|
+
type ProviderImplementationSourceAccess =
|
|
51
|
+
| "official_api"
|
|
52
|
+
| "private_api"
|
|
53
|
+
| "browser_flow"
|
|
54
|
+
| "hybrid";
|
|
55
|
+
|
|
56
|
+
type ProviderImplementationCredentialStrategy =
|
|
57
|
+
| "apifuse_managed"
|
|
58
|
+
| "workspace_secret"
|
|
59
|
+
| "user_oauth"
|
|
60
|
+
| "user_session"
|
|
61
|
+
| "none";
|
|
62
|
+
|
|
63
|
+
interface ProviderImplementationProfile {
|
|
64
|
+
sourceAccess: ProviderImplementationSourceAccess;
|
|
65
|
+
credentialStrategy: ProviderImplementationCredentialStrategy;
|
|
66
|
+
officialDocsUrl?: string;
|
|
67
|
+
operatorNotes?: string;
|
|
68
|
+
visibility: "internal" | "operator";
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
|
|
28
72
|
const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
29
73
|
const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
|
|
@@ -33,7 +77,91 @@ const VALID_AUTH_MODES = [
|
|
|
33
77
|
"credentials",
|
|
34
78
|
"oauth2",
|
|
35
79
|
] as const;
|
|
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;
|
|
91
|
+
const VALID_PROVIDER_PROXY_AFFINITIES = [
|
|
92
|
+
"request",
|
|
93
|
+
"operation",
|
|
94
|
+
"auth-flow",
|
|
95
|
+
"connection",
|
|
96
|
+
] as const;
|
|
97
|
+
const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
|
|
98
|
+
const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
|
|
36
99
|
const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
|
|
100
|
+
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;
|
|
118
|
+
const SSE_EVENT_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
|
|
119
|
+
const WEBSOCKET_SUBPROTOCOL_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
120
|
+
|
|
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
|
+
const MS_DURATION_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)?$/;
|
|
155
|
+
|
|
156
|
+
function isPositiveMsDurationString(value: unknown): value is string {
|
|
157
|
+
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);
|
|
164
|
+
}
|
|
37
165
|
|
|
38
166
|
type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
|
|
39
167
|
type OperationConfig<
|
|
@@ -43,7 +171,9 @@ type OperationConfig<
|
|
|
43
171
|
handler(
|
|
44
172
|
ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
|
|
45
173
|
input: InferSchemaOutput<TInput>,
|
|
46
|
-
):
|
|
174
|
+
):
|
|
175
|
+
| OperationHandlerResult<InferSchemaOutput<TOutput>>
|
|
176
|
+
| Promise<OperationHandlerResult<InferSchemaOutput<TOutput>>>;
|
|
47
177
|
};
|
|
48
178
|
type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
|
|
49
179
|
{
|
|
@@ -54,6 +184,51 @@ type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
|
|
|
54
184
|
? OperationConfig<TInput, TOutput>
|
|
55
185
|
: never;
|
|
56
186
|
};
|
|
187
|
+
type StreamOperationConfig<
|
|
188
|
+
TInput extends SchemaLike,
|
|
189
|
+
TOutput extends SchemaLike,
|
|
190
|
+
> =
|
|
191
|
+
| SseOperationConfig<TInput, TOutput>
|
|
192
|
+
| HttpStreamOperationConfig<TInput, TOutput>
|
|
193
|
+
| WebSocketOperationConfig<TInput, TOutput>;
|
|
194
|
+
type SseOperationConfig<
|
|
195
|
+
TInput extends SchemaLike,
|
|
196
|
+
TOutput extends SchemaLike,
|
|
197
|
+
> = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
|
|
198
|
+
transport: OperationSseTransport;
|
|
199
|
+
handler(
|
|
200
|
+
ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
|
|
201
|
+
input: InferSchemaOutput<TInput>,
|
|
202
|
+
):
|
|
203
|
+
| AsyncIterable<ProviderStreamEvent>
|
|
204
|
+
| Promise<AsyncIterable<ProviderStreamEvent>>;
|
|
205
|
+
};
|
|
206
|
+
type HttpStreamOperationConfig<
|
|
207
|
+
TInput extends SchemaLike,
|
|
208
|
+
TOutput extends SchemaLike,
|
|
209
|
+
> = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
|
|
210
|
+
transport: OperationHttpStreamTransport;
|
|
211
|
+
handler(
|
|
212
|
+
ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
|
|
213
|
+
input: InferSchemaOutput<TInput>,
|
|
214
|
+
):
|
|
215
|
+
| Response
|
|
216
|
+
| ReadableStream<Uint8Array>
|
|
217
|
+
| Promise<Response | ReadableStream<Uint8Array>>;
|
|
218
|
+
};
|
|
219
|
+
type WebSocketOperationConfig<
|
|
220
|
+
TInput extends SchemaLike,
|
|
221
|
+
TOutput extends SchemaLike,
|
|
222
|
+
> = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
|
|
223
|
+
transport: OperationWebSocketTransport;
|
|
224
|
+
handler(
|
|
225
|
+
ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
|
|
226
|
+
input: InferSchemaOutput<TInput>,
|
|
227
|
+
):
|
|
228
|
+
| Response
|
|
229
|
+
| ReadableStream<Uint8Array>
|
|
230
|
+
| Promise<Response | ReadableStream<Uint8Array>>;
|
|
231
|
+
};
|
|
57
232
|
|
|
58
233
|
export interface ProviderConfig<
|
|
59
234
|
TOperations extends Record<string, ProviderOperation>,
|
|
@@ -62,29 +237,43 @@ export interface ProviderConfig<
|
|
|
62
237
|
version: string;
|
|
63
238
|
runtime: "standard" | "shared" | "browser";
|
|
64
239
|
allowedHosts?: string[];
|
|
65
|
-
stealth?: {
|
|
66
|
-
|
|
240
|
+
stealth?: {
|
|
241
|
+
profile: string;
|
|
242
|
+
platform: StealthPlatform;
|
|
243
|
+
};
|
|
244
|
+
proxy?: ProviderProxyConfig;
|
|
245
|
+
stt?: ProviderSttConfig;
|
|
67
246
|
browser?: { engine: BrowserEngine };
|
|
68
247
|
auth?: AuthConfig;
|
|
69
248
|
reviewed?: ProviderReviewed;
|
|
249
|
+
access?: ProviderAccessConfig;
|
|
70
250
|
secrets?: ProviderSecretDeclaration[];
|
|
71
251
|
credential?: CredentialDeclaration;
|
|
72
252
|
context?: ContextDeclaration;
|
|
73
253
|
meta: {
|
|
74
254
|
displayName: string;
|
|
75
|
-
|
|
255
|
+
displayNameKey?: string;
|
|
256
|
+
descriptionKey: string;
|
|
76
257
|
category: string;
|
|
77
258
|
tags?: string[];
|
|
78
259
|
icon?: string;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
260
|
+
docTitleKey?: string;
|
|
261
|
+
docDescriptionKey?: string;
|
|
262
|
+
docSummaryKey?: string;
|
|
263
|
+
docMarkdownKey?: string;
|
|
264
|
+
normalizationNotesKeys?: string[];
|
|
83
265
|
environment?: "staging";
|
|
84
266
|
purpose?: string;
|
|
267
|
+
purposeKey?: string;
|
|
268
|
+
publicProfile?: ProviderPublicProfile;
|
|
269
|
+
implementationProfile?: ProviderImplementationProfile;
|
|
270
|
+
contract?: {
|
|
271
|
+
publicSchemaFieldNames?: "normalized";
|
|
272
|
+
};
|
|
85
273
|
};
|
|
86
274
|
operations: OperationMapConfig<TOperations>;
|
|
87
275
|
healthMonitor?: ProviderHealthMonitorConfig;
|
|
276
|
+
healthJourneys?: readonly HealthJourneyDefinition[];
|
|
88
277
|
}
|
|
89
278
|
|
|
90
279
|
/** Define one provider operation with schema-driven handler inference. */
|
|
@@ -97,6 +286,16 @@ export function defineOperation<
|
|
|
97
286
|
return operation;
|
|
98
287
|
}
|
|
99
288
|
|
|
289
|
+
/** Define a non-JSON provider operation with explicit transport metadata. */
|
|
290
|
+
export function defineStreamOperation<
|
|
291
|
+
TInput extends SchemaLike,
|
|
292
|
+
TOutput extends SchemaLike,
|
|
293
|
+
>(
|
|
294
|
+
operation: StreamOperationConfig<TInput, TOutput>,
|
|
295
|
+
): OperationDefinition<TInput, TOutput> {
|
|
296
|
+
return operation;
|
|
297
|
+
}
|
|
298
|
+
|
|
100
299
|
function assertObjectConfig(
|
|
101
300
|
value: unknown,
|
|
102
301
|
): asserts value is Record<string, unknown> {
|
|
@@ -163,7 +362,189 @@ function validateProviderShape(config: unknown): void {
|
|
|
163
362
|
VALID_AUTH_MODES,
|
|
164
363
|
String(config.id),
|
|
165
364
|
);
|
|
365
|
+
const access = config.access;
|
|
366
|
+
if (access !== undefined) {
|
|
367
|
+
if (!access || typeof access !== "object" || Array.isArray(access)) {
|
|
368
|
+
throw new ValidationError(
|
|
369
|
+
`Provider "${String(config.id)}" has invalid access: must be an object.`,
|
|
370
|
+
{
|
|
371
|
+
fix: `Set access to { visibility?: "public" | "early_access" }.`,
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const accessRecord: Record<string, unknown> = Object.fromEntries(
|
|
376
|
+
Object.entries(access),
|
|
377
|
+
);
|
|
378
|
+
for (const key of Object.keys(accessRecord)) {
|
|
379
|
+
if (key !== "visibility") {
|
|
380
|
+
throw new ValidationError(`Unknown field "${key}" on access.`, {
|
|
381
|
+
fix: `Remove access.${key} or rename it to visibility.`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const visibility = accessRecord.visibility;
|
|
386
|
+
if (visibility !== undefined) {
|
|
387
|
+
if (typeof visibility !== "string") {
|
|
388
|
+
throw new ValidationError(
|
|
389
|
+
`Provider "${String(config.id)}" has invalid access.visibility: must be "public" or "early_access".`,
|
|
390
|
+
{
|
|
391
|
+
fix: `Set access.visibility to "public" or "early_access".`,
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
assertLiteralField(
|
|
396
|
+
visibility,
|
|
397
|
+
"access.visibility",
|
|
398
|
+
VALID_PROVIDER_ACCESS_VISIBILITIES,
|
|
399
|
+
String(config.id),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function validateProviderProxy(config: {
|
|
406
|
+
id: string;
|
|
407
|
+
proxy?: ProviderProxyConfig;
|
|
408
|
+
secrets?: ProviderSecretDeclaration[];
|
|
409
|
+
}): void {
|
|
410
|
+
const proxy = config.proxy;
|
|
411
|
+
if (proxy === undefined || typeof proxy === "boolean") {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) {
|
|
415
|
+
throw new ValidationError(
|
|
416
|
+
`Provider "${config.id}" has invalid proxy: must be a boolean or provider proxy policy object.`,
|
|
417
|
+
{
|
|
418
|
+
fix: `Use proxy: { mode: "required", provider: "smartproxy", geo: { country: "KR" }, session: { affinity: "connection", lifetimeMinutes: 30 } }`,
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
rejectUnknownFields(
|
|
423
|
+
proxy,
|
|
424
|
+
new Set(["mode", "provider", "geo", "session"]),
|
|
425
|
+
"proxy",
|
|
426
|
+
);
|
|
427
|
+
assertLiteralField(
|
|
428
|
+
proxy.mode,
|
|
429
|
+
"proxy.mode",
|
|
430
|
+
VALID_PROVIDER_PROXY_MODES,
|
|
431
|
+
config.id,
|
|
432
|
+
);
|
|
433
|
+
if (proxy.provider !== undefined) {
|
|
434
|
+
assertLiteralField(
|
|
435
|
+
proxy.provider,
|
|
436
|
+
"proxy.provider",
|
|
437
|
+
VALID_PROVIDER_PROXY_PROVIDERS,
|
|
438
|
+
config.id,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (proxy.geo !== undefined) {
|
|
442
|
+
if (
|
|
443
|
+
!proxy.geo ||
|
|
444
|
+
typeof proxy.geo !== "object" ||
|
|
445
|
+
Array.isArray(proxy.geo)
|
|
446
|
+
) {
|
|
447
|
+
throw new ValidationError(
|
|
448
|
+
`Provider "${config.id}" has invalid proxy.geo: must be an object.`,
|
|
449
|
+
{
|
|
450
|
+
fix: `Use proxy.geo: { country: "KR" } with ISO alpha-2 country codes.`,
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
rejectUnknownFields(
|
|
455
|
+
proxy.geo,
|
|
456
|
+
new Set(["country", "subdivision", "city"]),
|
|
457
|
+
"proxy.geo",
|
|
458
|
+
);
|
|
459
|
+
if (proxy.geo.country !== undefined) {
|
|
460
|
+
assertIsoCountry(proxy.geo.country, "proxy.geo.country");
|
|
461
|
+
}
|
|
462
|
+
for (const field of ["subdivision", "city"] as const) {
|
|
463
|
+
const value = proxy.geo[field];
|
|
464
|
+
if (value !== undefined && (typeof value !== "string" || !value.trim())) {
|
|
465
|
+
throw new ValidationError(
|
|
466
|
+
`Provider "${config.id}" has invalid proxy.geo.${field}: must be a non-empty string.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (proxy.session !== undefined) {
|
|
472
|
+
if (
|
|
473
|
+
!proxy.session ||
|
|
474
|
+
typeof proxy.session !== "object" ||
|
|
475
|
+
Array.isArray(proxy.session)
|
|
476
|
+
) {
|
|
477
|
+
throw new ValidationError(
|
|
478
|
+
`Provider "${config.id}" has invalid proxy.session: must be an object.`,
|
|
479
|
+
{
|
|
480
|
+
fix: `Use proxy.session: { affinity: "connection", lifetimeMinutes: 30 }.`,
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
rejectUnknownFields(
|
|
485
|
+
proxy.session,
|
|
486
|
+
new Set(["affinity", "lifetimeMinutes", "poolSize"]),
|
|
487
|
+
"proxy.session",
|
|
488
|
+
);
|
|
489
|
+
if (proxy.session.affinity !== undefined) {
|
|
490
|
+
assertLiteralField(
|
|
491
|
+
proxy.session.affinity,
|
|
492
|
+
"proxy.session.affinity",
|
|
493
|
+
VALID_PROVIDER_PROXY_AFFINITIES,
|
|
494
|
+
config.id,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const lifetime = proxy.session.lifetimeMinutes;
|
|
498
|
+
if (
|
|
499
|
+
lifetime !== undefined &&
|
|
500
|
+
(!Number.isFinite(lifetime) || lifetime <= 0)
|
|
501
|
+
) {
|
|
502
|
+
throw new ValidationError(
|
|
503
|
+
`Provider "${config.id}" has invalid proxy.session.lifetimeMinutes: must be a positive number of minutes.`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const poolSize = proxy.session.poolSize;
|
|
507
|
+
if (
|
|
508
|
+
poolSize !== undefined &&
|
|
509
|
+
(!Number.isInteger(poolSize) || poolSize <= 0)
|
|
510
|
+
) {
|
|
511
|
+
throw new ValidationError(
|
|
512
|
+
`Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (proxy.mode === "required" && proxy.provider === "smartproxy") {
|
|
517
|
+
const hasSmartproxySecret = config.secrets?.some(
|
|
518
|
+
(secret) =>
|
|
519
|
+
secret.name === SMARTPROXY_APP_KEY_SECRET && secret.required !== false,
|
|
520
|
+
);
|
|
521
|
+
if (!hasSmartproxySecret) {
|
|
522
|
+
throw new ValidationError(
|
|
523
|
+
`Provider "${config.id}" requires Smartproxy egress but does not declare ${SMARTPROXY_APP_KEY_SECRET}.`,
|
|
524
|
+
{
|
|
525
|
+
fix: `Add secrets: [{ name: "${SMARTPROXY_APP_KEY_SECRET}", required: true }] to the provider.`,
|
|
526
|
+
},
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function validateProviderStt(config: {
|
|
533
|
+
id: string;
|
|
534
|
+
stt?: ProviderSttConfig;
|
|
535
|
+
}): void {
|
|
536
|
+
const stt = config.stt;
|
|
537
|
+
if (stt === undefined) return;
|
|
538
|
+
if (!stt || typeof stt !== "object" || Array.isArray(stt)) {
|
|
539
|
+
throw new ValidationError(
|
|
540
|
+
`Provider "${config.id}" has invalid stt: must be an object.`,
|
|
541
|
+
{ fix: `Use stt: { mode: "required" } or stt: { mode: "optional" }.` },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
rejectUnknownFields(stt, new Set(["mode"]), "stt");
|
|
545
|
+
assertLiteralField(stt.mode, "stt.mode", VALID_PROVIDER_STT_MODES, config.id);
|
|
166
546
|
}
|
|
547
|
+
|
|
167
548
|
function validateOperationIds(
|
|
168
549
|
providerId: string,
|
|
169
550
|
operations: Record<string, ProviderOperation>,
|
|
@@ -187,6 +568,8 @@ function validateOperationIds(
|
|
|
187
568
|
}
|
|
188
569
|
const OPERATION_CONTRACT_VERSION_REGEX =
|
|
189
570
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
571
|
+
const OPERATION_SENSITIVE_PATH_REGEX =
|
|
572
|
+
/^(?:[A-Za-z0-9_$-]+|\*)(?:\.(?:[A-Za-z0-9_$-]+|\*))*$/;
|
|
190
573
|
const VALID_OPERATION_LIFECYCLES = [
|
|
191
574
|
"stable",
|
|
192
575
|
"beta",
|
|
@@ -208,6 +591,63 @@ function assertNonEmptyString(
|
|
|
208
591
|
}
|
|
209
592
|
}
|
|
210
593
|
|
|
594
|
+
function validateToolRouterMetadata(
|
|
595
|
+
providerId: string,
|
|
596
|
+
operations: Record<string, ProviderOperation>,
|
|
597
|
+
): void {
|
|
598
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
599
|
+
const toolRouter = operation.toolRouter;
|
|
600
|
+
if (toolRouter === undefined) continue;
|
|
601
|
+
if (!toolRouter || typeof toolRouter !== "object") {
|
|
602
|
+
throw new ValidationError(
|
|
603
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter: must be an object.`,
|
|
604
|
+
{
|
|
605
|
+
fix: `Remove operations.${operationName}.toolRouter or provide MCP-safe metadata.`,
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
toolRouter.name !== undefined &&
|
|
611
|
+
!MCP_TOOL_NAME_REGEX.test(toolRouter.name)
|
|
612
|
+
) {
|
|
613
|
+
throw new ValidationError(
|
|
614
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter.name: expected an MCP-safe name.`,
|
|
615
|
+
{
|
|
616
|
+
fix: `Use letters, numbers, and underscores only, starting with a letter, for example "${providerId.replace(/[^A-Za-z0-9]+/g, "_")}__${operationName.replace(/[^A-Za-z0-9]+/g, "_")}".`,
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (toolRouter.riskClass !== undefined) {
|
|
621
|
+
assertLiteralField(
|
|
622
|
+
toolRouter.riskClass,
|
|
623
|
+
`operations.${operationName}.toolRouter.riskClass`,
|
|
624
|
+
VALID_OPERATION_RISK_CLASSES,
|
|
625
|
+
providerId,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (toolRouter.approval !== undefined) {
|
|
629
|
+
assertLiteralField(
|
|
630
|
+
toolRouter.approval,
|
|
631
|
+
`operations.${operationName}.toolRouter.approval`,
|
|
632
|
+
VALID_OPERATION_APPROVAL_POLICIES,
|
|
633
|
+
providerId,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (
|
|
637
|
+
toolRouter.connectionExternalRefParam !== undefined &&
|
|
638
|
+
(typeof toolRouter.connectionExternalRefParam !== "string" ||
|
|
639
|
+
toolRouter.connectionExternalRefParam.trim().length === 0)
|
|
640
|
+
) {
|
|
641
|
+
throw new ValidationError(
|
|
642
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter.connectionExternalRefParam: must be a non-empty string.`,
|
|
643
|
+
{
|
|
644
|
+
fix: `Use "externalRef" unless the operation has a documented public alias.`,
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
211
651
|
function validateOperationContracts(
|
|
212
652
|
providerId: string,
|
|
213
653
|
operations: Record<string, ProviderOperation>,
|
|
@@ -305,80 +745,449 @@ function validateOperationAnnotations(
|
|
|
305
745
|
}
|
|
306
746
|
}
|
|
307
747
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
748
|
+
function validateOperationObservability(
|
|
749
|
+
providerId: string,
|
|
750
|
+
operations: Record<string, ProviderOperation>,
|
|
751
|
+
): void {
|
|
752
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
753
|
+
const observability = operation.observability;
|
|
754
|
+
if (observability === undefined) continue;
|
|
755
|
+
if (!observability || typeof observability !== "object") {
|
|
756
|
+
throw new ValidationError(
|
|
757
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability: must be an object.`,
|
|
758
|
+
{
|
|
759
|
+
fix: `Use observability: { sensitive: { input: ["field"], output: ["items.*.secret"] } }.`,
|
|
760
|
+
},
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
rejectUnknownFields(
|
|
764
|
+
observability,
|
|
765
|
+
new Set(["sensitive"]),
|
|
766
|
+
`operations.${operationName}.observability`,
|
|
767
|
+
);
|
|
768
|
+
const sensitive = observability.sensitive;
|
|
769
|
+
if (sensitive === undefined) continue;
|
|
770
|
+
if (!sensitive || typeof sensitive !== "object") {
|
|
771
|
+
throw new ValidationError(
|
|
772
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive: must be an object.`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
rejectUnknownFields(
|
|
776
|
+
sensitive,
|
|
777
|
+
new Set(["input", "output"]),
|
|
778
|
+
`operations.${operationName}.observability.sensitive`,
|
|
779
|
+
);
|
|
780
|
+
for (const side of ["input", "output"] as const) {
|
|
781
|
+
const paths = sensitive[side];
|
|
782
|
+
if (paths === undefined) continue;
|
|
783
|
+
if (!Array.isArray(paths)) {
|
|
784
|
+
throw new ValidationError(
|
|
785
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive.${side}: must be an array of dot paths.`,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
for (const [index, path] of paths.entries()) {
|
|
789
|
+
if (
|
|
790
|
+
typeof path !== "string" ||
|
|
791
|
+
path.trim() !== path ||
|
|
792
|
+
!OPERATION_SENSITIVE_PATH_REGEX.test(path)
|
|
793
|
+
) {
|
|
794
|
+
throw new ValidationError(
|
|
795
|
+
`Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive.${side}[${index}]: expected dot path segments or "*" wildcards.`,
|
|
796
|
+
{
|
|
797
|
+
fix: `Use paths like "password" or "items.*.phone"; do not include empty segments, brackets, or leading/trailing spaces.`,
|
|
798
|
+
},
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
347
802
|
}
|
|
348
|
-
for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
|
|
349
803
|
}
|
|
350
|
-
return prev[n] ?? 0;
|
|
351
804
|
}
|
|
352
805
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
806
|
+
const JSON_TRANSPORT_FIELDS = new Set(["kind"]);
|
|
807
|
+
const SSE_TRANSPORT_FIELDS = new Set([
|
|
808
|
+
"kind",
|
|
809
|
+
"heartbeatMs",
|
|
810
|
+
"idleTimeoutMs",
|
|
811
|
+
"maxDurationMs",
|
|
812
|
+
"maxEventBytes",
|
|
813
|
+
"resumable",
|
|
814
|
+
"events",
|
|
815
|
+
]);
|
|
816
|
+
const HTTP_STREAM_TRANSPORT_FIELDS = new Set([
|
|
817
|
+
"kind",
|
|
818
|
+
"contentType",
|
|
819
|
+
"idleTimeoutMs",
|
|
820
|
+
"maxDurationMs",
|
|
821
|
+
"maxChunkBytes",
|
|
822
|
+
]);
|
|
823
|
+
const WEBSOCKET_TRANSPORT_FIELDS = new Set([
|
|
824
|
+
"kind",
|
|
825
|
+
"subprotocols",
|
|
826
|
+
"idleTimeoutMs",
|
|
827
|
+
"maxDurationMs",
|
|
828
|
+
"maxFrameBytes",
|
|
829
|
+
"dispatch",
|
|
830
|
+
]);
|
|
831
|
+
|
|
832
|
+
function assertTransportObject(
|
|
833
|
+
transport: unknown,
|
|
834
|
+
fieldPath: string,
|
|
835
|
+
providerId: string,
|
|
836
|
+
operationName: string,
|
|
837
|
+
): asserts transport is OperationTransport {
|
|
838
|
+
if (!transport || typeof transport !== "object" || Array.isArray(transport)) {
|
|
839
|
+
throw new ValidationError(
|
|
840
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must be a transport object.`,
|
|
841
|
+
{
|
|
842
|
+
fix: `Use ${fieldPath}: { kind: "sse", ... } or omit ${fieldPath} for JSON operations.`,
|
|
843
|
+
},
|
|
844
|
+
);
|
|
365
845
|
}
|
|
366
|
-
return best;
|
|
367
846
|
}
|
|
368
847
|
|
|
369
|
-
function
|
|
370
|
-
value:
|
|
371
|
-
allowed: ReadonlySet<string>,
|
|
848
|
+
function assertStreamMs(
|
|
849
|
+
value: unknown,
|
|
372
850
|
fieldPath: string,
|
|
851
|
+
min: number,
|
|
852
|
+
max: number,
|
|
853
|
+
label: string,
|
|
373
854
|
): void {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
855
|
+
if (value === undefined) return;
|
|
856
|
+
assertBoundedIntegerMs(value, fieldPath, { min, max, label });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function assertPositiveBytes(value: unknown, fieldPath: string): void {
|
|
860
|
+
if (value === undefined) return;
|
|
861
|
+
if (
|
|
862
|
+
typeof value !== "number" ||
|
|
863
|
+
!Number.isInteger(value) ||
|
|
864
|
+
value < STREAM_CHUNK_BYTES_MIN ||
|
|
865
|
+
value > STREAM_CHUNK_BYTES_MAX
|
|
866
|
+
) {
|
|
377
867
|
throw new ValidationError(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
: `
|
|
381
|
-
|
|
868
|
+
`${fieldPath} must be an integer byte size in [${STREAM_CHUNK_BYTES_MIN}, ${STREAM_CHUNK_BYTES_MAX}].`,
|
|
869
|
+
{
|
|
870
|
+
fix: `Set ${fieldPath} to an integer byte size no larger than ${STREAM_CHUNK_BYTES_MAX}.`,
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function validateSseEvents(
|
|
877
|
+
value: unknown,
|
|
878
|
+
fieldPath: string,
|
|
879
|
+
providerId: string,
|
|
880
|
+
operationName: string,
|
|
881
|
+
): void {
|
|
882
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
883
|
+
throw new ValidationError(
|
|
884
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must be an object keyed by SSE event name.`,
|
|
885
|
+
{
|
|
886
|
+
fix: `Set ${fieldPath} to an object, for example delta: z.object({ ... }). SSE transports require explicit event schemas.`,
|
|
887
|
+
},
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
if (Object.keys(value).length === 0) {
|
|
891
|
+
throw new ValidationError(
|
|
892
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must declare at least one SSE event schema.`,
|
|
893
|
+
{
|
|
894
|
+
fix: `Declare every emitted event, for example ${fieldPath}: { delta: z.object({ ... }) }.`,
|
|
895
|
+
},
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
for (const [eventName, schema] of Object.entries(value)) {
|
|
899
|
+
if (!SSE_EVENT_NAME_REGEX.test(eventName)) {
|
|
900
|
+
throw new ValidationError(
|
|
901
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.${eventName}: event names must be SSE-safe identifiers.`,
|
|
902
|
+
{
|
|
903
|
+
fix: `Use letters, numbers, underscore, dash, or dot, starting with a letter.`,
|
|
904
|
+
},
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
if (!schema || typeof schema !== "object") {
|
|
908
|
+
throw new ValidationError(
|
|
909
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.${eventName}: event schema must be a schema object.`,
|
|
910
|
+
{
|
|
911
|
+
fix: `Set ${fieldPath}.${eventName} to a Zod or Standard Schema object.`,
|
|
912
|
+
},
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function validateOperationTransports(
|
|
919
|
+
providerId: string,
|
|
920
|
+
operations: Record<string, ProviderOperation>,
|
|
921
|
+
): void {
|
|
922
|
+
for (const [operationName, operation] of Object.entries(operations)) {
|
|
923
|
+
const transport = operation.transport;
|
|
924
|
+
if (transport === undefined) continue;
|
|
925
|
+
const fieldPath = `operations.${operationName}.transport`;
|
|
926
|
+
assertTransportObject(transport, fieldPath, providerId, operationName);
|
|
927
|
+
const kind = Reflect.get(transport, "kind");
|
|
928
|
+
if (typeof kind !== "string") {
|
|
929
|
+
throw new ValidationError(
|
|
930
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.kind: must be a string.`,
|
|
931
|
+
{
|
|
932
|
+
fix: `Set ${fieldPath}.kind to one of ${VALID_OPERATION_TRANSPORT_KINDS.map((item) => `"${item}"`).join(", ")}.`,
|
|
933
|
+
},
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
assertLiteralField(
|
|
937
|
+
kind,
|
|
938
|
+
`${fieldPath}.kind`,
|
|
939
|
+
VALID_OPERATION_TRANSPORT_KINDS,
|
|
940
|
+
providerId,
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
switch (kind) {
|
|
944
|
+
case "json":
|
|
945
|
+
rejectUnknownFields(transport, JSON_TRANSPORT_FIELDS, fieldPath);
|
|
946
|
+
break;
|
|
947
|
+
case "sse": {
|
|
948
|
+
rejectUnknownFields(transport, SSE_TRANSPORT_FIELDS, fieldPath);
|
|
949
|
+
const heartbeatMs = Reflect.get(transport, "heartbeatMs");
|
|
950
|
+
const idleTimeoutMs = Reflect.get(transport, "idleTimeoutMs");
|
|
951
|
+
const maxDurationMs = Reflect.get(transport, "maxDurationMs");
|
|
952
|
+
assertStreamMs(
|
|
953
|
+
heartbeatMs,
|
|
954
|
+
`${fieldPath}.heartbeatMs`,
|
|
955
|
+
STREAM_HEARTBEAT_MS_MIN,
|
|
956
|
+
STREAM_HEARTBEAT_MS_MAX,
|
|
957
|
+
"heartbeat",
|
|
958
|
+
);
|
|
959
|
+
assertStreamMs(
|
|
960
|
+
idleTimeoutMs,
|
|
961
|
+
`${fieldPath}.idleTimeoutMs`,
|
|
962
|
+
STREAM_IDLE_TIMEOUT_MS_MIN,
|
|
963
|
+
STREAM_IDLE_TIMEOUT_MS_MAX,
|
|
964
|
+
"idle timeout",
|
|
965
|
+
);
|
|
966
|
+
assertStreamMs(
|
|
967
|
+
maxDurationMs,
|
|
968
|
+
`${fieldPath}.maxDurationMs`,
|
|
969
|
+
STREAM_MAX_DURATION_MS_MIN,
|
|
970
|
+
STREAM_MAX_DURATION_MS_MAX,
|
|
971
|
+
"max duration",
|
|
972
|
+
);
|
|
973
|
+
assertPositiveBytes(
|
|
974
|
+
Reflect.get(transport, "maxEventBytes"),
|
|
975
|
+
`${fieldPath}.maxEventBytes`,
|
|
976
|
+
);
|
|
977
|
+
const resumable = Reflect.get(transport, "resumable");
|
|
978
|
+
if (
|
|
979
|
+
resumable !== undefined &&
|
|
980
|
+
resumable !== false &&
|
|
981
|
+
resumable !== "last-event-id"
|
|
982
|
+
) {
|
|
983
|
+
throw new ValidationError(
|
|
984
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.resumable: expected false or "last-event-id".`,
|
|
985
|
+
{
|
|
986
|
+
fix: `Use ${fieldPath}.resumable: "last-event-id" for SSE Last-Event-ID resume support, or false to disable resume.`,
|
|
987
|
+
},
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
validateSseEvents(
|
|
991
|
+
Reflect.get(transport, "events"),
|
|
992
|
+
`${fieldPath}.events`,
|
|
993
|
+
providerId,
|
|
994
|
+
operationName,
|
|
995
|
+
);
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
case "http-stream": {
|
|
999
|
+
rejectUnknownFields(transport, HTTP_STREAM_TRANSPORT_FIELDS, fieldPath);
|
|
1000
|
+
const contentType = Reflect.get(transport, "contentType");
|
|
1001
|
+
if (contentType !== undefined) {
|
|
1002
|
+
assertNonEmptyString(
|
|
1003
|
+
contentType,
|
|
1004
|
+
`${fieldPath}.contentType`,
|
|
1005
|
+
providerId,
|
|
1006
|
+
operationName,
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
assertStreamMs(
|
|
1010
|
+
Reflect.get(transport, "idleTimeoutMs"),
|
|
1011
|
+
`${fieldPath}.idleTimeoutMs`,
|
|
1012
|
+
STREAM_IDLE_TIMEOUT_MS_MIN,
|
|
1013
|
+
STREAM_IDLE_TIMEOUT_MS_MAX,
|
|
1014
|
+
"idle timeout",
|
|
1015
|
+
);
|
|
1016
|
+
assertStreamMs(
|
|
1017
|
+
Reflect.get(transport, "maxDurationMs"),
|
|
1018
|
+
`${fieldPath}.maxDurationMs`,
|
|
1019
|
+
STREAM_MAX_DURATION_MS_MIN,
|
|
1020
|
+
STREAM_MAX_DURATION_MS_MAX,
|
|
1021
|
+
"max duration",
|
|
1022
|
+
);
|
|
1023
|
+
assertPositiveBytes(
|
|
1024
|
+
Reflect.get(transport, "maxChunkBytes"),
|
|
1025
|
+
`${fieldPath}.maxChunkBytes`,
|
|
1026
|
+
);
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
case "websocket": {
|
|
1030
|
+
rejectUnknownFields(transport, WEBSOCKET_TRANSPORT_FIELDS, fieldPath);
|
|
1031
|
+
const dispatch = Reflect.get(transport, "dispatch");
|
|
1032
|
+
if (dispatch !== "unsupported") {
|
|
1033
|
+
throw new ValidationError(
|
|
1034
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.dispatch: websocket dispatch is future-ready only.`,
|
|
1035
|
+
{
|
|
1036
|
+
fix: `Use ${fieldPath}.dispatch: "unsupported" until gateway-managed sessions are implemented.`,
|
|
1037
|
+
},
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const subprotocols = Reflect.get(transport, "subprotocols");
|
|
1041
|
+
if (subprotocols !== undefined) {
|
|
1042
|
+
if (!Array.isArray(subprotocols)) {
|
|
1043
|
+
throw new ValidationError(
|
|
1044
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.subprotocols: must be an array.`,
|
|
1045
|
+
{
|
|
1046
|
+
fix: `Set ${fieldPath}.subprotocols to an array of WebSocket subprotocol tokens.`,
|
|
1047
|
+
},
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
for (const subprotocol of subprotocols) {
|
|
1051
|
+
if (
|
|
1052
|
+
typeof subprotocol !== "string" ||
|
|
1053
|
+
!WEBSOCKET_SUBPROTOCOL_REGEX.test(subprotocol)
|
|
1054
|
+
) {
|
|
1055
|
+
throw new ValidationError(
|
|
1056
|
+
`Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.subprotocols: each subprotocol must be an RFC token string.`,
|
|
1057
|
+
{
|
|
1058
|
+
fix: `Use values such as "apifuse.v1" without spaces or separators that are invalid for Sec-WebSocket-Protocol.`,
|
|
1059
|
+
},
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
assertStreamMs(
|
|
1065
|
+
Reflect.get(transport, "idleTimeoutMs"),
|
|
1066
|
+
`${fieldPath}.idleTimeoutMs`,
|
|
1067
|
+
STREAM_IDLE_TIMEOUT_MS_MIN,
|
|
1068
|
+
STREAM_IDLE_TIMEOUT_MS_MAX,
|
|
1069
|
+
"idle timeout",
|
|
1070
|
+
);
|
|
1071
|
+
assertStreamMs(
|
|
1072
|
+
Reflect.get(transport, "maxDurationMs"),
|
|
1073
|
+
`${fieldPath}.maxDurationMs`,
|
|
1074
|
+
STREAM_MAX_DURATION_MS_MIN,
|
|
1075
|
+
STREAM_MAX_DURATION_MS_MAX,
|
|
1076
|
+
"max duration",
|
|
1077
|
+
);
|
|
1078
|
+
assertPositiveBytes(
|
|
1079
|
+
Reflect.get(transport, "maxFrameBytes"),
|
|
1080
|
+
`${fieldPath}.maxFrameBytes`,
|
|
1081
|
+
);
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const HEALTH_CHECK_SUITE_FIELDS = new Set([
|
|
1089
|
+
"interval",
|
|
1090
|
+
"timeoutMs",
|
|
1091
|
+
"degradedThresholdMs",
|
|
1092
|
+
"cases",
|
|
1093
|
+
"requiresConnection",
|
|
1094
|
+
]);
|
|
1095
|
+
const HEALTH_CHECK_CASE_FIELDS = new Set([
|
|
1096
|
+
"name",
|
|
1097
|
+
"description",
|
|
1098
|
+
"input",
|
|
1099
|
+
"assertions",
|
|
1100
|
+
"degradedThresholdMs",
|
|
1101
|
+
"timeoutMs",
|
|
1102
|
+
"expectedStatus",
|
|
1103
|
+
"enabled",
|
|
1104
|
+
]);
|
|
1105
|
+
const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
|
|
1106
|
+
const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
|
|
1107
|
+
"defaultProbeTimeoutMs",
|
|
1108
|
+
"defaultDegradedThresholdMs",
|
|
1109
|
+
"requiredSecrets",
|
|
1110
|
+
"credentialInputs",
|
|
1111
|
+
"probeOverrides",
|
|
1112
|
+
"serviceAccount",
|
|
1113
|
+
]);
|
|
1114
|
+
const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set([
|
|
1115
|
+
"interval",
|
|
1116
|
+
"timeoutMs",
|
|
1117
|
+
"degradedThresholdMs",
|
|
1118
|
+
]);
|
|
1119
|
+
|
|
1120
|
+
function levenshtein(a: string, b: string): number {
|
|
1121
|
+
const m = a.length;
|
|
1122
|
+
const n = b.length;
|
|
1123
|
+
if (m === 0) return n;
|
|
1124
|
+
if (n === 0) return m;
|
|
1125
|
+
const prev = new Array<number>(n + 1);
|
|
1126
|
+
const curr = new Array<number>(n + 1);
|
|
1127
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
1128
|
+
for (let i = 1; i <= m; i++) {
|
|
1129
|
+
curr[0] = i;
|
|
1130
|
+
for (let j = 1; j <= n; j++) {
|
|
1131
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
1132
|
+
const deletion = (prev[j] ?? 0) + 1;
|
|
1133
|
+
const insertion = (curr[j - 1] ?? 0) + 1;
|
|
1134
|
+
const substitution = (prev[j - 1] ?? 0) + cost;
|
|
1135
|
+
curr[j] = Math.min(deletion, insertion, substitution);
|
|
1136
|
+
}
|
|
1137
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j] ?? 0;
|
|
1138
|
+
}
|
|
1139
|
+
return prev[n] ?? 0;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function suggestField(
|
|
1143
|
+
unknown: string,
|
|
1144
|
+
candidates: ReadonlySet<string>,
|
|
1145
|
+
): string | undefined {
|
|
1146
|
+
let best: string | undefined;
|
|
1147
|
+
let bestDist = 3;
|
|
1148
|
+
for (const candidate of candidates) {
|
|
1149
|
+
const dist = levenshtein(unknown, candidate);
|
|
1150
|
+
if (dist < bestDist) {
|
|
1151
|
+
bestDist = dist;
|
|
1152
|
+
best = candidate;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return best;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function rejectUnknownFields(
|
|
1159
|
+
value: object,
|
|
1160
|
+
allowed: ReadonlySet<string>,
|
|
1161
|
+
fieldPath: string,
|
|
1162
|
+
): void {
|
|
1163
|
+
for (const key of Object.keys(value)) {
|
|
1164
|
+
if (allowed.has(key)) continue;
|
|
1165
|
+
const hint = suggestField(key, allowed);
|
|
1166
|
+
throw new ValidationError(
|
|
1167
|
+
hint
|
|
1168
|
+
? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
|
|
1169
|
+
: `Unknown field "${key}" on ${fieldPath}.`,
|
|
1170
|
+
{ fix: `Remove ${fieldPath}.${key} or rename it.` },
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function assertBoundedIntegerMs(
|
|
1176
|
+
value: unknown,
|
|
1177
|
+
fieldPath: string,
|
|
1178
|
+
options: { min: number; max: number; label: string },
|
|
1179
|
+
): void {
|
|
1180
|
+
if (
|
|
1181
|
+
typeof value !== "number" ||
|
|
1182
|
+
!Number.isInteger(value) ||
|
|
1183
|
+
value < options.min ||
|
|
1184
|
+
value > options.max
|
|
1185
|
+
) {
|
|
1186
|
+
throw new ValidationError(
|
|
1187
|
+
`${fieldPath} must be an integer ${options.label} in [${options.min}, ${options.max}] ms.`,
|
|
1188
|
+
{
|
|
1189
|
+
fix: `Set ${fieldPath} to an integer in [${options.min}, ${options.max}] ms.`,
|
|
1190
|
+
},
|
|
382
1191
|
);
|
|
383
1192
|
}
|
|
384
1193
|
}
|
|
@@ -405,6 +1214,28 @@ function validateProviderHealthMonitor(
|
|
|
405
1214
|
PROVIDER_HEALTH_MONITOR_FIELDS,
|
|
406
1215
|
"healthMonitor",
|
|
407
1216
|
);
|
|
1217
|
+
if (healthMonitorRecord.defaultProbeTimeoutMs !== undefined) {
|
|
1218
|
+
assertBoundedIntegerMs(
|
|
1219
|
+
healthMonitorRecord.defaultProbeTimeoutMs,
|
|
1220
|
+
`Provider "${providerId}" healthMonitor.defaultProbeTimeoutMs`,
|
|
1221
|
+
{
|
|
1222
|
+
min: HEALTH_CHECK_TIMEOUT_MS_MIN,
|
|
1223
|
+
max: HEALTH_CHECK_TIMEOUT_MS_MAX,
|
|
1224
|
+
label: "timeout",
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (healthMonitorRecord.defaultDegradedThresholdMs !== undefined) {
|
|
1229
|
+
assertBoundedIntegerMs(
|
|
1230
|
+
healthMonitorRecord.defaultDegradedThresholdMs,
|
|
1231
|
+
`Provider "${providerId}" healthMonitor.defaultDegradedThresholdMs`,
|
|
1232
|
+
{
|
|
1233
|
+
min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
|
|
1234
|
+
max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
|
|
1235
|
+
label: "degraded threshold",
|
|
1236
|
+
},
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
408
1239
|
const requiredSecrets = healthMonitorRecord.requiredSecrets;
|
|
409
1240
|
if (requiredSecrets !== undefined) {
|
|
410
1241
|
if (!Array.isArray(requiredSecrets))
|
|
@@ -418,6 +1249,36 @@ function validateProviderHealthMonitor(
|
|
|
418
1249
|
);
|
|
419
1250
|
}
|
|
420
1251
|
}
|
|
1252
|
+
const credentialInputs = healthMonitorRecord.credentialInputs;
|
|
1253
|
+
if (credentialInputs !== undefined) {
|
|
1254
|
+
if (
|
|
1255
|
+
!credentialInputs ||
|
|
1256
|
+
typeof credentialInputs !== "object" ||
|
|
1257
|
+
Array.isArray(credentialInputs)
|
|
1258
|
+
) {
|
|
1259
|
+
throw new ValidationError(
|
|
1260
|
+
`Provider "${providerId}" has invalid healthMonitor.credentialInputs: must be an object mapping auth input fields to env var names.`,
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
for (const [field, envVar] of Object.entries(credentialInputs)) {
|
|
1264
|
+
if (field.trim().length === 0) {
|
|
1265
|
+
throw new ValidationError(
|
|
1266
|
+
`Provider "${providerId}" has invalid healthMonitor.credentialInputs key: must be a non-empty auth input field.`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
if (typeof envVar !== "string" || envVar.trim().length === 0) {
|
|
1270
|
+
throw new ValidationError(
|
|
1271
|
+
`Provider "${providerId}" has invalid healthMonitor.credentialInputs.${field}: must be a non-empty env var name.`,
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
if (Array.isArray(requiredSecrets) && !requiredSecrets.includes(envVar)) {
|
|
1275
|
+
throw new ValidationError(
|
|
1276
|
+
`Provider "${providerId}" healthMonitor.credentialInputs.${field} references ${envVar}, which must also be listed in healthMonitor.requiredSecrets.`,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
421
1282
|
const probeOverrides = healthMonitorRecord.probeOverrides;
|
|
422
1283
|
if (probeOverrides !== undefined) {
|
|
423
1284
|
if (
|
|
@@ -444,15 +1305,32 @@ function validateProviderHealthMonitor(
|
|
|
444
1305
|
`healthMonitor.probeOverrides["${probeId}"]`,
|
|
445
1306
|
);
|
|
446
1307
|
const interval = overrideRecord.interval;
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
interval !== undefined &&
|
|
450
|
-
(typeof interval !== "string" ||
|
|
451
|
-
!validProbeIntervals.includes(interval))
|
|
452
|
-
)
|
|
1308
|
+
if (interval !== undefined && !isPositiveMsDurationString(interval))
|
|
453
1309
|
throw new ValidationError(
|
|
454
|
-
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be
|
|
1310
|
+
`Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be a positive ms-style duration string such as 30s, 5m, 8h, or 1 day.`,
|
|
455
1311
|
);
|
|
1312
|
+
if (overrideRecord.timeoutMs !== undefined) {
|
|
1313
|
+
assertBoundedIntegerMs(
|
|
1314
|
+
overrideRecord.timeoutMs,
|
|
1315
|
+
`Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].timeoutMs`,
|
|
1316
|
+
{
|
|
1317
|
+
min: HEALTH_CHECK_TIMEOUT_MS_MIN,
|
|
1318
|
+
max: HEALTH_CHECK_TIMEOUT_MS_MAX,
|
|
1319
|
+
label: "timeout",
|
|
1320
|
+
},
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
if (overrideRecord.degradedThresholdMs !== undefined) {
|
|
1324
|
+
assertBoundedIntegerMs(
|
|
1325
|
+
overrideRecord.degradedThresholdMs,
|
|
1326
|
+
`Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].degradedThresholdMs`,
|
|
1327
|
+
{
|
|
1328
|
+
min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
|
|
1329
|
+
max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
|
|
1330
|
+
label: "degraded threshold",
|
|
1331
|
+
},
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
456
1334
|
}
|
|
457
1335
|
}
|
|
458
1336
|
const serviceAccount = healthMonitorRecord.serviceAccount;
|
|
@@ -496,12 +1374,24 @@ function validateHealthCheckCase(
|
|
|
496
1374
|
if (
|
|
497
1375
|
c.degradedThresholdMs !== undefined &&
|
|
498
1376
|
(typeof c.degradedThresholdMs !== "number" ||
|
|
499
|
-
!Number.
|
|
500
|
-
c.degradedThresholdMs
|
|
1377
|
+
!Number.isInteger(c.degradedThresholdMs) ||
|
|
1378
|
+
c.degradedThresholdMs < HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN ||
|
|
1379
|
+
c.degradedThresholdMs > HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX)
|
|
501
1380
|
)
|
|
502
1381
|
throw new ValidationError(
|
|
503
|
-
`Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be
|
|
1382
|
+
`Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be an integer degraded threshold in [${HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN}, ${HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX}] ms.`,
|
|
504
1383
|
);
|
|
1384
|
+
if (c.timeoutMs !== undefined) {
|
|
1385
|
+
assertBoundedIntegerMs(
|
|
1386
|
+
c.timeoutMs,
|
|
1387
|
+
`Provider "${providerId}" ${fieldPath}.timeoutMs`,
|
|
1388
|
+
{
|
|
1389
|
+
min: HEALTH_CHECK_TIMEOUT_MS_MIN,
|
|
1390
|
+
max: HEALTH_CHECK_TIMEOUT_MS_MAX,
|
|
1391
|
+
label: "timeout",
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
505
1395
|
if (
|
|
506
1396
|
c.expectedStatus !== undefined &&
|
|
507
1397
|
c.expectedStatus !== "ok" &&
|
|
@@ -532,25 +1422,34 @@ function validateHealthCheckSuite(
|
|
|
532
1422
|
fieldPath,
|
|
533
1423
|
);
|
|
534
1424
|
const s = suite as HealthCheckSuite;
|
|
535
|
-
if (
|
|
536
|
-
typeof s.interval !== "string" ||
|
|
537
|
-
!PROBE_INTERVALS.includes(s.interval as ProbeInterval)
|
|
538
|
-
)
|
|
1425
|
+
if (!isPositiveMsDurationString(s.interval))
|
|
539
1426
|
throw new ValidationError(
|
|
540
|
-
`Provider "${providerId}" ${fieldPath}.interval must be
|
|
1427
|
+
`Provider "${providerId}" ${fieldPath}.interval must be a positive ms-style duration string such as 30s, 5m, 8h, or 1 day.`,
|
|
541
1428
|
{
|
|
542
|
-
fix: `Set ${fieldPath}.interval to a
|
|
1429
|
+
fix: `Set ${fieldPath}.interval to a positive ms-style duration string.`,
|
|
543
1430
|
},
|
|
544
1431
|
);
|
|
545
1432
|
if (s.timeoutMs !== undefined) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1433
|
+
assertBoundedIntegerMs(
|
|
1434
|
+
s.timeoutMs,
|
|
1435
|
+
`Provider "${providerId}" ${fieldPath}.timeoutMs`,
|
|
1436
|
+
{
|
|
1437
|
+
min: HEALTH_CHECK_TIMEOUT_MS_MIN,
|
|
1438
|
+
max: HEALTH_CHECK_TIMEOUT_MS_MAX,
|
|
1439
|
+
label: "timeout",
|
|
1440
|
+
},
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
if (s.degradedThresholdMs !== undefined) {
|
|
1444
|
+
assertBoundedIntegerMs(
|
|
1445
|
+
s.degradedThresholdMs,
|
|
1446
|
+
`Provider "${providerId}" ${fieldPath}.degradedThresholdMs`,
|
|
1447
|
+
{
|
|
1448
|
+
min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
|
|
1449
|
+
max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
|
|
1450
|
+
label: "degraded threshold",
|
|
1451
|
+
},
|
|
1452
|
+
);
|
|
554
1453
|
}
|
|
555
1454
|
if (
|
|
556
1455
|
s.requiresConnection !== undefined &&
|
|
@@ -614,9 +1513,607 @@ function validateHealthCheckUnsupported(
|
|
|
614
1513
|
);
|
|
615
1514
|
}
|
|
616
1515
|
|
|
1516
|
+
const HEALTH_JOURNEY_FIELDS = new Set([
|
|
1517
|
+
"id",
|
|
1518
|
+
"title",
|
|
1519
|
+
"description",
|
|
1520
|
+
"schedule",
|
|
1521
|
+
"coversOperations",
|
|
1522
|
+
"timeout",
|
|
1523
|
+
"cooldown",
|
|
1524
|
+
"smsMatchers",
|
|
1525
|
+
"requiredSecrets",
|
|
1526
|
+
"manualTrigger",
|
|
1527
|
+
"steps",
|
|
1528
|
+
"run",
|
|
1529
|
+
]);
|
|
1530
|
+
const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter"]);
|
|
1531
|
+
const HEALTH_JOURNEY_STEP_FIELDS = new Set([
|
|
1532
|
+
"id",
|
|
1533
|
+
"description",
|
|
1534
|
+
"operationId",
|
|
1535
|
+
"usesSmsMatcher",
|
|
1536
|
+
"coversOperations",
|
|
1537
|
+
"safeBoundary",
|
|
1538
|
+
"kind",
|
|
1539
|
+
]);
|
|
1540
|
+
|
|
1541
|
+
const HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS = new Set([
|
|
1542
|
+
"enabled",
|
|
1543
|
+
"reason",
|
|
1544
|
+
"requiresAcknowledgement",
|
|
1545
|
+
"risk",
|
|
1546
|
+
"minManualInterval",
|
|
1547
|
+
"publicRationale",
|
|
1548
|
+
]);
|
|
1549
|
+
const HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS = new Set([
|
|
1550
|
+
"enabled",
|
|
1551
|
+
"reason",
|
|
1552
|
+
]);
|
|
1553
|
+
const HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS = new Set([
|
|
1554
|
+
"enabled",
|
|
1555
|
+
"requiresAcknowledgement",
|
|
1556
|
+
"risk",
|
|
1557
|
+
"minManualInterval",
|
|
1558
|
+
"publicRationale",
|
|
1559
|
+
]);
|
|
1560
|
+
const HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS = new Set([
|
|
1561
|
+
"read_only",
|
|
1562
|
+
"writes_external_state",
|
|
1563
|
+
"sms_or_payment",
|
|
1564
|
+
]);
|
|
1565
|
+
|
|
1566
|
+
function validateHealthJourneyManualTrigger(
|
|
1567
|
+
providerId: string,
|
|
1568
|
+
journeyId: string,
|
|
1569
|
+
manualTrigger: unknown,
|
|
1570
|
+
): void {
|
|
1571
|
+
const fieldPath = `healthJourneys.${journeyId}.manualTrigger`;
|
|
1572
|
+
if (
|
|
1573
|
+
!manualTrigger ||
|
|
1574
|
+
typeof manualTrigger !== "object" ||
|
|
1575
|
+
Array.isArray(manualTrigger)
|
|
1576
|
+
) {
|
|
1577
|
+
throw new ValidationError(
|
|
1578
|
+
`Provider "${providerId}" ${fieldPath} must be an object when present.`,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
rejectUnknownFields(
|
|
1582
|
+
manualTrigger,
|
|
1583
|
+
HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS,
|
|
1584
|
+
fieldPath,
|
|
1585
|
+
);
|
|
1586
|
+
const enabled = Reflect.get(manualTrigger, "enabled");
|
|
1587
|
+
if (typeof enabled !== "boolean") {
|
|
1588
|
+
throw new ValidationError(
|
|
1589
|
+
`Provider "${providerId}" ${fieldPath}.enabled must be a boolean.`,
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
if (enabled === false) {
|
|
1593
|
+
rejectUnknownFields(
|
|
1594
|
+
manualTrigger,
|
|
1595
|
+
HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS,
|
|
1596
|
+
fieldPath,
|
|
1597
|
+
);
|
|
1598
|
+
if (
|
|
1599
|
+
Reflect.get(manualTrigger, "reason") !== undefined &&
|
|
1600
|
+
(typeof Reflect.get(manualTrigger, "reason") !== "string" ||
|
|
1601
|
+
Reflect.get(manualTrigger, "reason") === "")
|
|
1602
|
+
) {
|
|
1603
|
+
throw new ValidationError(
|
|
1604
|
+
`Provider "${providerId}" ${fieldPath}.reason must be a non-empty string when present.`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
rejectUnknownFields(
|
|
1610
|
+
manualTrigger,
|
|
1611
|
+
HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS,
|
|
1612
|
+
fieldPath,
|
|
1613
|
+
);
|
|
1614
|
+
const requiresAcknowledgement = Reflect.get(
|
|
1615
|
+
manualTrigger,
|
|
1616
|
+
"requiresAcknowledgement",
|
|
1617
|
+
);
|
|
1618
|
+
if (typeof requiresAcknowledgement !== "boolean") {
|
|
1619
|
+
throw new ValidationError(
|
|
1620
|
+
`Provider "${providerId}" ${fieldPath}.requiresAcknowledgement must be a boolean.`,
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
const risk = Reflect.get(manualTrigger, "risk");
|
|
1624
|
+
if (
|
|
1625
|
+
typeof risk !== "string" ||
|
|
1626
|
+
!HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS.has(risk)
|
|
1627
|
+
) {
|
|
1628
|
+
throw new ValidationError(
|
|
1629
|
+
`Provider "${providerId}" ${fieldPath}.risk must be one of read_only, writes_external_state, or sms_or_payment.`,
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
if (risk !== "read_only" && requiresAcknowledgement !== true) {
|
|
1633
|
+
throw new ValidationError(
|
|
1634
|
+
`Provider "${providerId}" ${fieldPath}.requiresAcknowledgement must be true when risk is writes_external_state or sms_or_payment.`,
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
const minManualInterval = Reflect.get(manualTrigger, "minManualInterval");
|
|
1638
|
+
assertIsoDuration(
|
|
1639
|
+
minManualInterval,
|
|
1640
|
+
`Provider "${providerId}" ${fieldPath}.minManualInterval`,
|
|
1641
|
+
);
|
|
1642
|
+
if (isoDurationMs(minManualInterval) <= 0) {
|
|
1643
|
+
throw new ValidationError(
|
|
1644
|
+
`Provider "${providerId}" ${fieldPath}.minManualInterval must be a positive duration.`,
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
const rationale = Reflect.get(manualTrigger, "publicRationale");
|
|
1648
|
+
if (typeof rationale !== "string" || rationale.trim().length === 0) {
|
|
1649
|
+
throw new ValidationError(
|
|
1650
|
+
`Provider "${providerId}" ${fieldPath}.publicRationale must be a non-empty string.`,
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const SMS_OTP_MATCHER_FIELDS = new Set([
|
|
1656
|
+
"id",
|
|
1657
|
+
"country",
|
|
1658
|
+
"locale",
|
|
1659
|
+
"phoneNumber",
|
|
1660
|
+
"origins",
|
|
1661
|
+
"code",
|
|
1662
|
+
"maxAge",
|
|
1663
|
+
"waitTimeout",
|
|
1664
|
+
"clockSkew",
|
|
1665
|
+
"extractOtp",
|
|
1666
|
+
]);
|
|
1667
|
+
const SMS_OTP_CODE_FIELDS = new Set(["pattern", "capture"]);
|
|
1668
|
+
const SMS_ORIGIN_FIELDS_BY_KIND: Record<string, ReadonlySet<string>> = {
|
|
1669
|
+
e164: new Set(["kind", "value", "display"]),
|
|
1670
|
+
nationalServiceCode: new Set(["kind", "country", "value", "display"]),
|
|
1671
|
+
};
|
|
1672
|
+
const DURATION_RE =
|
|
1673
|
+
/^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
|
|
1674
|
+
const E164_RE = /^\+[1-9]\d{1,14}$/;
|
|
1675
|
+
const ISO_COUNTRY_RE = /^[A-Z]{2}$/;
|
|
1676
|
+
const NATIONAL_SERVICE_CODE_RE = /^[0-9]{2,15}$/;
|
|
1677
|
+
const BCP47_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
1678
|
+
const JOURNEY_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
1679
|
+
|
|
1680
|
+
function assertIsoDuration(
|
|
1681
|
+
value: unknown,
|
|
1682
|
+
fieldPath: string,
|
|
1683
|
+
): asserts value is string {
|
|
1684
|
+
if (typeof value !== "string" || !DURATION_RE.test(value)) {
|
|
1685
|
+
throw new ValidationError(
|
|
1686
|
+
`${fieldPath} must be an ISO 8601 duration for example PT8H or PT2M30S.`,
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function isoDurationMs(value: string): number {
|
|
1692
|
+
const match = DURATION_RE.exec(value);
|
|
1693
|
+
if (!match) return 0;
|
|
1694
|
+
const days = Number(/(\d+)D/.exec(value)?.[1] ?? 0);
|
|
1695
|
+
const hours = Number(/(\d+)H/.exec(value)?.[1] ?? 0);
|
|
1696
|
+
const minutes = Number(/(\d+)M/.exec(value)?.[1] ?? 0);
|
|
1697
|
+
const seconds = Number(/(\d+(?:\.\d+)?)S/.exec(value)?.[1] ?? 0);
|
|
1698
|
+
return (
|
|
1699
|
+
days * 86_400_000 + hours * 3_600_000 + minutes * 60_000 + seconds * 1_000
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function assertIsoCountry(
|
|
1704
|
+
value: unknown,
|
|
1705
|
+
fieldPath: string,
|
|
1706
|
+
): asserts value is string {
|
|
1707
|
+
if (typeof value !== "string" || !ISO_COUNTRY_RE.test(value)) {
|
|
1708
|
+
throw new ValidationError(
|
|
1709
|
+
`${fieldPath} must be an ISO 3166-1 alpha-2 country code for example KR.`,
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function normalizeIntervalDuration(input: string): string {
|
|
1715
|
+
const trimmed = input.trim();
|
|
1716
|
+
const shorthand = /^(\d+)(s|m|h|d)$/i.exec(trimmed);
|
|
1717
|
+
if (shorthand) {
|
|
1718
|
+
const amount = Number(shorthand[1]);
|
|
1719
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
1720
|
+
throw new ValidationError(
|
|
1721
|
+
`Journey schedule interval must be a positive duration.`,
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
const unit = shorthand[2]?.toLowerCase();
|
|
1725
|
+
if (unit === "s") return `PT${amount}S`;
|
|
1726
|
+
if (unit === "m") return `PT${amount}M`;
|
|
1727
|
+
if (unit === "h") return `PT${amount}H`;
|
|
1728
|
+
if (unit === "d") return `P${amount}D`;
|
|
1729
|
+
}
|
|
1730
|
+
assertIsoDuration(trimmed, "journey schedule interval");
|
|
1731
|
+
return trimmed;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
export function every(
|
|
1735
|
+
interval: string,
|
|
1736
|
+
options: { jitter?: string } = {},
|
|
1737
|
+
): HealthJourneySchedule {
|
|
1738
|
+
const schedule: HealthJourneySchedule = {
|
|
1739
|
+
kind: "interval",
|
|
1740
|
+
interval: normalizeIntervalDuration(interval),
|
|
1741
|
+
};
|
|
1742
|
+
if (options.jitter !== undefined) {
|
|
1743
|
+
schedule.jitter = normalizeIntervalDuration(options.jitter);
|
|
1744
|
+
}
|
|
1745
|
+
return schedule;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function countCapturingGroups(pattern: RegExp): number {
|
|
1749
|
+
let count = 0;
|
|
1750
|
+
const source = pattern.source;
|
|
1751
|
+
let inCharacterClass = false;
|
|
1752
|
+
for (let i = 0; i < source.length; i++) {
|
|
1753
|
+
const char = source[i];
|
|
1754
|
+
if (isRegexCharEscaped(source, i)) continue;
|
|
1755
|
+
if (char === "[") {
|
|
1756
|
+
inCharacterClass = true;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
if (char === "]") {
|
|
1760
|
+
inCharacterClass = false;
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (inCharacterClass || char !== "(") continue;
|
|
1764
|
+
const next = source[i + 1];
|
|
1765
|
+
if (next === "?" && source[i + 2] !== "<") continue;
|
|
1766
|
+
if (
|
|
1767
|
+
next === "?" &&
|
|
1768
|
+
source[i + 2] === "<" &&
|
|
1769
|
+
(source[i + 3] === "=" || source[i + 3] === "!")
|
|
1770
|
+
)
|
|
1771
|
+
continue;
|
|
1772
|
+
count += 1;
|
|
1773
|
+
}
|
|
1774
|
+
return count;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function isRegexCharEscaped(source: string, index: number): boolean {
|
|
1778
|
+
let backslashes = 0;
|
|
1779
|
+
for (let i = index - 1; i >= 0 && source[i] === "\\"; i--) backslashes += 1;
|
|
1780
|
+
return backslashes % 2 === 1;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function validateSmsOrigin(origin: unknown, fieldPath: string): void {
|
|
1784
|
+
if (!origin || typeof origin !== "object" || Array.isArray(origin)) {
|
|
1785
|
+
throw new ValidationError(`${fieldPath} must be an object.`);
|
|
1786
|
+
}
|
|
1787
|
+
const kind = Reflect.get(origin, "kind");
|
|
1788
|
+
if (kind !== "e164" && kind !== "nationalServiceCode") {
|
|
1789
|
+
throw new ValidationError(
|
|
1790
|
+
`${fieldPath}.kind must be "e164" or "nationalServiceCode".`,
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
rejectUnknownFields(origin, SMS_ORIGIN_FIELDS_BY_KIND[kind], fieldPath);
|
|
1794
|
+
if (kind === "e164") {
|
|
1795
|
+
if (
|
|
1796
|
+
typeof Reflect.get(origin, "value") !== "string" ||
|
|
1797
|
+
!E164_RE.test(Reflect.get(origin, "value"))
|
|
1798
|
+
) {
|
|
1799
|
+
throw new ValidationError(
|
|
1800
|
+
`${fieldPath}.value must be an ITU-T E.164 number for example +821012345678.`,
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
} else {
|
|
1804
|
+
assertIsoCountry(Reflect.get(origin, "country"), `${fieldPath}.country`);
|
|
1805
|
+
if (
|
|
1806
|
+
typeof Reflect.get(origin, "value") !== "string" ||
|
|
1807
|
+
!NATIONAL_SERVICE_CODE_RE.test(Reflect.get(origin, "value"))
|
|
1808
|
+
) {
|
|
1809
|
+
throw new ValidationError(
|
|
1810
|
+
`${fieldPath}.value must be digits only for a national service code.`,
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (
|
|
1815
|
+
Reflect.get(origin, "display") !== undefined &&
|
|
1816
|
+
typeof Reflect.get(origin, "display") !== "string"
|
|
1817
|
+
) {
|
|
1818
|
+
throw new ValidationError(
|
|
1819
|
+
`${fieldPath}.display must be a string when present.`,
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function validateSmsOtpMatcher(
|
|
1825
|
+
matcher: unknown,
|
|
1826
|
+
fieldPath: string,
|
|
1827
|
+
): asserts matcher is SmsOtpMatcherDefinition {
|
|
1828
|
+
if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) {
|
|
1829
|
+
throw new ValidationError(`${fieldPath} must be an object.`);
|
|
1830
|
+
}
|
|
1831
|
+
rejectUnknownFields(matcher, SMS_OTP_MATCHER_FIELDS, fieldPath);
|
|
1832
|
+
const matcherId = Reflect.get(matcher, "id");
|
|
1833
|
+
if (typeof matcherId !== "string" || !JOURNEY_ID_RE.test(matcherId)) {
|
|
1834
|
+
throw new ValidationError(
|
|
1835
|
+
`${fieldPath}.id must be a kebab-case identifier.`,
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
assertIsoCountry(Reflect.get(matcher, "country"), `${fieldPath}.country`);
|
|
1839
|
+
if (
|
|
1840
|
+
Reflect.get(matcher, "locale") !== undefined &&
|
|
1841
|
+
(typeof Reflect.get(matcher, "locale") !== "string" ||
|
|
1842
|
+
!BCP47_RE.test(Reflect.get(matcher, "locale")))
|
|
1843
|
+
) {
|
|
1844
|
+
throw new ValidationError(
|
|
1845
|
+
`${fieldPath}.locale must be a BCP 47 locale for example ko-KR.`,
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
if (
|
|
1849
|
+
Reflect.get(matcher, "phoneNumber") !== undefined &&
|
|
1850
|
+
(typeof Reflect.get(matcher, "phoneNumber") !== "string" ||
|
|
1851
|
+
!E164_RE.test(Reflect.get(matcher, "phoneNumber")))
|
|
1852
|
+
) {
|
|
1853
|
+
throw new ValidationError(
|
|
1854
|
+
`${fieldPath}.phoneNumber must be an ITU-T E.164 number.`,
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
const origins = Reflect.get(matcher, "origins");
|
|
1858
|
+
if (!Array.isArray(origins) || origins.length === 0) {
|
|
1859
|
+
throw new ValidationError(
|
|
1860
|
+
`${fieldPath}.origins must be a non-empty array.`,
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
for (const [index, origin] of origins.entries()) {
|
|
1864
|
+
validateSmsOrigin(origin, `${fieldPath}.origins[${index}]`);
|
|
1865
|
+
}
|
|
1866
|
+
if (
|
|
1867
|
+
!Reflect.get(matcher, "code") ||
|
|
1868
|
+
typeof Reflect.get(matcher, "code") !== "object" ||
|
|
1869
|
+
Array.isArray(Reflect.get(matcher, "code"))
|
|
1870
|
+
) {
|
|
1871
|
+
throw new ValidationError(`${fieldPath}.code must be an object.`);
|
|
1872
|
+
}
|
|
1873
|
+
const code = Reflect.get(matcher, "code");
|
|
1874
|
+
rejectUnknownFields(code, SMS_OTP_CODE_FIELDS, `${fieldPath}.code`);
|
|
1875
|
+
const pattern = Reflect.get(code, "pattern");
|
|
1876
|
+
if (!(pattern instanceof RegExp) && typeof pattern !== "string") {
|
|
1877
|
+
throw new ValidationError(
|
|
1878
|
+
`${fieldPath}.code.pattern must be a RegExp or pattern source string.`,
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern);
|
|
1882
|
+
if (
|
|
1883
|
+
countCapturingGroups(regex) !== 1 &&
|
|
1884
|
+
Reflect.get(code, "capture") === undefined
|
|
1885
|
+
) {
|
|
1886
|
+
throw new ValidationError(
|
|
1887
|
+
`${fieldPath}.code.pattern must contain exactly one OTP capture or declare code.capture.`,
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
if (
|
|
1891
|
+
Reflect.get(code, "capture") !== undefined &&
|
|
1892
|
+
typeof Reflect.get(code, "capture") !== "string" &&
|
|
1893
|
+
typeof Reflect.get(code, "capture") !== "number"
|
|
1894
|
+
) {
|
|
1895
|
+
throw new ValidationError(
|
|
1896
|
+
`${fieldPath}.code.capture must be a string or number when present.`,
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
assertIsoDuration(Reflect.get(matcher, "maxAge"), `${fieldPath}.maxAge`);
|
|
1900
|
+
assertIsoDuration(
|
|
1901
|
+
Reflect.get(matcher, "waitTimeout"),
|
|
1902
|
+
`${fieldPath}.waitTimeout`,
|
|
1903
|
+
);
|
|
1904
|
+
if (Reflect.get(matcher, "clockSkew") !== undefined)
|
|
1905
|
+
assertIsoDuration(
|
|
1906
|
+
Reflect.get(matcher, "clockSkew"),
|
|
1907
|
+
`${fieldPath}.clockSkew`,
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
export function defineSmsOtpMatcher(
|
|
1912
|
+
config: Omit<SmsOtpMatcherDefinition, "extractOtp">,
|
|
1913
|
+
): SmsOtpMatcherDefinition {
|
|
1914
|
+
const rawPattern = config.code.pattern;
|
|
1915
|
+
const pattern =
|
|
1916
|
+
rawPattern instanceof RegExp
|
|
1917
|
+
? new RegExp(rawPattern.source, rawPattern.flags)
|
|
1918
|
+
: new RegExp(rawPattern);
|
|
1919
|
+
const matcher = {
|
|
1920
|
+
...config,
|
|
1921
|
+
extractOtp(body: string): string | null {
|
|
1922
|
+
pattern.lastIndex = 0;
|
|
1923
|
+
const match = pattern.exec(body);
|
|
1924
|
+
pattern.lastIndex = 0;
|
|
1925
|
+
if (!match) return null;
|
|
1926
|
+
const capture = config.code.capture;
|
|
1927
|
+
const code =
|
|
1928
|
+
typeof capture === "string"
|
|
1929
|
+
? match.groups?.[capture]
|
|
1930
|
+
: typeof capture === "number"
|
|
1931
|
+
? match[capture]
|
|
1932
|
+
: match[1];
|
|
1933
|
+
return typeof code === "string" ? code : null;
|
|
1934
|
+
},
|
|
1935
|
+
};
|
|
1936
|
+
validateSmsOtpMatcher(matcher, "smsOtpMatcher");
|
|
1937
|
+
return matcher;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
export function defineHealthJourney(
|
|
1941
|
+
config: HealthJourneyDefinition,
|
|
1942
|
+
): HealthJourneyDefinition {
|
|
1943
|
+
return config;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function validateHealthJourneySchedule(
|
|
1947
|
+
providerId: string,
|
|
1948
|
+
journeyId: string,
|
|
1949
|
+
schedule: unknown,
|
|
1950
|
+
): void {
|
|
1951
|
+
const fieldPath = `healthJourneys.${journeyId}.schedule`;
|
|
1952
|
+
if (!schedule || typeof schedule !== "object" || Array.isArray(schedule)) {
|
|
1953
|
+
throw new ValidationError(
|
|
1954
|
+
`Provider "${providerId}" ${fieldPath} must be an object.`,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
rejectUnknownFields(schedule, HEALTH_JOURNEY_SCHEDULE_FIELDS, fieldPath);
|
|
1958
|
+
if (Reflect.get(schedule, "kind") !== "interval")
|
|
1959
|
+
throw new ValidationError(
|
|
1960
|
+
`Provider "${providerId}" ${fieldPath}.kind must be "interval".`,
|
|
1961
|
+
);
|
|
1962
|
+
assertIsoDuration(
|
|
1963
|
+
Reflect.get(schedule, "interval"),
|
|
1964
|
+
`Provider "${providerId}" ${fieldPath}.interval`,
|
|
1965
|
+
);
|
|
1966
|
+
if (Reflect.get(schedule, "jitter") !== undefined)
|
|
1967
|
+
assertIsoDuration(
|
|
1968
|
+
Reflect.get(schedule, "jitter"),
|
|
1969
|
+
`Provider "${providerId}" ${fieldPath}.jitter`,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function validateHealthJourneys(
|
|
1974
|
+
providerId: string,
|
|
1975
|
+
operations: Record<string, ProviderOperation>,
|
|
1976
|
+
healthJourneys: readonly HealthJourneyDefinition[] | undefined,
|
|
1977
|
+
): Set<string> {
|
|
1978
|
+
const covered = new Set<string>();
|
|
1979
|
+
if (healthJourneys === undefined) return covered;
|
|
1980
|
+
if (!Array.isArray(healthJourneys)) {
|
|
1981
|
+
throw new ValidationError(
|
|
1982
|
+
`Provider "${providerId}" healthJourneys must be an array.`,
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
const journeyIds = new Set<string>();
|
|
1986
|
+
for (const [index, journey] of healthJourneys.entries()) {
|
|
1987
|
+
const prefix = `healthJourneys[${index}]`;
|
|
1988
|
+
if (!journey || typeof journey !== "object" || Array.isArray(journey)) {
|
|
1989
|
+
throw new ValidationError(
|
|
1990
|
+
`Provider "${providerId}" ${prefix} must be an object.`,
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
rejectUnknownFields(journey, HEALTH_JOURNEY_FIELDS, prefix);
|
|
1994
|
+
if (typeof journey.id !== "string" || !JOURNEY_ID_RE.test(journey.id)) {
|
|
1995
|
+
throw new ValidationError(
|
|
1996
|
+
`Provider "${providerId}" ${prefix}.id must be a kebab-case identifier.`,
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
if (journeyIds.has(journey.id))
|
|
2000
|
+
throw new ValidationError(
|
|
2001
|
+
`Provider "${providerId}" has duplicate health journey id "${journey.id}".`,
|
|
2002
|
+
);
|
|
2003
|
+
journeyIds.add(journey.id);
|
|
2004
|
+
validateHealthJourneySchedule(providerId, journey.id, journey.schedule);
|
|
2005
|
+
if (
|
|
2006
|
+
!Array.isArray(journey.coversOperations) ||
|
|
2007
|
+
journey.coversOperations.length === 0
|
|
2008
|
+
) {
|
|
2009
|
+
throw new ValidationError(
|
|
2010
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.coversOperations must be a non-empty array.`,
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
for (const operationId of journey.coversOperations) {
|
|
2014
|
+
if (typeof operationId !== "string" || operationId.length === 0) {
|
|
2015
|
+
throw new ValidationError(
|
|
2016
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.coversOperations contains an invalid operation id.`,
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
if (!operations[operationId]) {
|
|
2020
|
+
throw new ValidationError(
|
|
2021
|
+
`Provider "${providerId}" health journey "${journey.id}" covers unknown operation "${operationId}".`,
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
if (operations[operationId].healthCheckUnsupported) {
|
|
2025
|
+
throw new ValidationError(
|
|
2026
|
+
`Provider "${providerId}" health journey "${journey.id}" cannot cover unsupported operation "${operationId}".`,
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
covered.add(operationId);
|
|
2030
|
+
}
|
|
2031
|
+
if (!Array.isArray(journey.steps) || journey.steps.length === 0) {
|
|
2032
|
+
throw new ValidationError(
|
|
2033
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.steps must be a non-empty array.`,
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
const matcherIds = new Set<string>();
|
|
2037
|
+
if (journey.smsMatchers !== undefined) {
|
|
2038
|
+
if (!Array.isArray(journey.smsMatchers))
|
|
2039
|
+
throw new ValidationError(
|
|
2040
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers must be an array.`,
|
|
2041
|
+
);
|
|
2042
|
+
for (const [matcherIndex, matcher] of journey.smsMatchers.entries()) {
|
|
2043
|
+
validateSmsOtpMatcher(
|
|
2044
|
+
matcher,
|
|
2045
|
+
`healthJourneys.${journey.id}.smsMatchers[${matcherIndex}]`,
|
|
2046
|
+
);
|
|
2047
|
+
if (matcherIds.has(matcher.id))
|
|
2048
|
+
throw new ValidationError(
|
|
2049
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers has duplicate matcher id "${matcher.id}".`,
|
|
2050
|
+
);
|
|
2051
|
+
matcherIds.add(matcher.id);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
for (const [stepIndex, step] of journey.steps.entries()) {
|
|
2055
|
+
const stepPath = `healthJourneys.${journey.id}.steps[${stepIndex}]`;
|
|
2056
|
+
if (!step || typeof step !== "object" || Array.isArray(step))
|
|
2057
|
+
throw new ValidationError(
|
|
2058
|
+
`Provider "${providerId}" ${stepPath} must be an object.`,
|
|
2059
|
+
);
|
|
2060
|
+
rejectUnknownFields(step, HEALTH_JOURNEY_STEP_FIELDS, stepPath);
|
|
2061
|
+
if (typeof step.id !== "string" || !JOURNEY_ID_RE.test(step.id))
|
|
2062
|
+
throw new ValidationError(
|
|
2063
|
+
`Provider "${providerId}" ${stepPath}.id must be a kebab-case identifier.`,
|
|
2064
|
+
);
|
|
2065
|
+
if (step.operationId !== undefined && !operations[step.operationId])
|
|
2066
|
+
throw new ValidationError(
|
|
2067
|
+
`Provider "${providerId}" ${stepPath}.operationId references unknown operation "${step.operationId}".`,
|
|
2068
|
+
);
|
|
2069
|
+
if (
|
|
2070
|
+
step.usesSmsMatcher !== undefined &&
|
|
2071
|
+
!matcherIds.has(step.usesSmsMatcher)
|
|
2072
|
+
)
|
|
2073
|
+
throw new ValidationError(
|
|
2074
|
+
`Provider "${providerId}" ${stepPath}.usesSmsMatcher references unknown matcher "${step.usesSmsMatcher}".`,
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
if (journey.manualTrigger !== undefined)
|
|
2078
|
+
validateHealthJourneyManualTrigger(
|
|
2079
|
+
providerId,
|
|
2080
|
+
journey.id,
|
|
2081
|
+
journey.manualTrigger,
|
|
2082
|
+
);
|
|
2083
|
+
if (journey.timeout !== undefined)
|
|
2084
|
+
assertIsoDuration(
|
|
2085
|
+
journey.timeout,
|
|
2086
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.timeout`,
|
|
2087
|
+
);
|
|
2088
|
+
if (journey.cooldown !== undefined)
|
|
2089
|
+
assertIsoDuration(
|
|
2090
|
+
journey.cooldown,
|
|
2091
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.cooldown`,
|
|
2092
|
+
);
|
|
2093
|
+
if (journey.run !== undefined && typeof journey.run !== "function") {
|
|
2094
|
+
throw new ValidationError(
|
|
2095
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.run must be a function when present.`,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
if (journey.requiredSecrets !== undefined) {
|
|
2099
|
+
if (!Array.isArray(journey.requiredSecrets))
|
|
2100
|
+
throw new ValidationError(
|
|
2101
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.requiredSecrets must be an array.`,
|
|
2102
|
+
);
|
|
2103
|
+
for (const secret of journey.requiredSecrets)
|
|
2104
|
+
if (typeof secret !== "string" || secret.length === 0)
|
|
2105
|
+
throw new ValidationError(
|
|
2106
|
+
`Provider "${providerId}" healthJourneys.${journey.id}.requiredSecrets entries must be non-empty strings.`,
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
return covered;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
617
2113
|
function validateOperationHealthChecks(
|
|
618
2114
|
providerId: string,
|
|
619
2115
|
operations: Record<string, ProviderOperation>,
|
|
2116
|
+
journeyCoveredOperations: ReadonlySet<string> = new Set(),
|
|
620
2117
|
): void {
|
|
621
2118
|
for (const [operationName, operation] of Object.entries(operations)) {
|
|
622
2119
|
const hasCheck = operation.healthCheck !== undefined;
|
|
@@ -640,7 +2137,11 @@ function validateOperationHealthChecks(
|
|
|
640
2137
|
operationName,
|
|
641
2138
|
operation.healthCheckUnsupported,
|
|
642
2139
|
);
|
|
643
|
-
if (
|
|
2140
|
+
if (
|
|
2141
|
+
!hasCheck &&
|
|
2142
|
+
!hasUnsupported &&
|
|
2143
|
+
!journeyCoveredOperations.has(operationName)
|
|
2144
|
+
)
|
|
644
2145
|
throw new ValidationError(
|
|
645
2146
|
`Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
|
|
646
2147
|
{
|
|
@@ -712,10 +2213,24 @@ export function defineProvider<
|
|
|
712
2213
|
);
|
|
713
2214
|
validateOperationIds(config.id, config.operations);
|
|
714
2215
|
validateOperationAnnotations(config.id, config.operations);
|
|
2216
|
+
validateOperationObservability(config.id, config.operations);
|
|
2217
|
+
validateOperationTransports(config.id, config.operations);
|
|
715
2218
|
validateOperationContracts(config.id, config.operations);
|
|
716
|
-
|
|
2219
|
+
validateToolRouterMetadata(config.id, config.operations);
|
|
2220
|
+
const journeyCoveredOperations = validateHealthJourneys(
|
|
2221
|
+
config.id,
|
|
2222
|
+
config.operations,
|
|
2223
|
+
config.healthJourneys,
|
|
2224
|
+
);
|
|
2225
|
+
validateOperationHealthChecks(
|
|
2226
|
+
config.id,
|
|
2227
|
+
config.operations,
|
|
2228
|
+
journeyCoveredOperations,
|
|
2229
|
+
);
|
|
717
2230
|
validateProviderHealthMonitor(config.id, config.healthMonitor);
|
|
718
2231
|
validateOperationFixtures(config.id, config.operations);
|
|
2232
|
+
validateProviderProxy(config);
|
|
2233
|
+
validateProviderStt(config);
|
|
719
2234
|
if (config.runtime === "browser" && !config.browser)
|
|
720
2235
|
throw new ProviderError(
|
|
721
2236
|
`Provider "${config.id}" must define browser.engine when runtime is "browser"`,
|
|
@@ -728,10 +2243,6 @@ export function defineProvider<
|
|
|
728
2243
|
`Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
|
|
729
2244
|
{ fix: 'Set runtime: "browser" or remove the browser config' },
|
|
730
2245
|
);
|
|
731
|
-
if (config.proxy && !config.stealth)
|
|
732
|
-
console.warn(
|
|
733
|
-
`[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
|
|
734
|
-
);
|
|
735
2246
|
return {
|
|
736
2247
|
id: config.id,
|
|
737
2248
|
version: config.version,
|
|
@@ -739,14 +2250,17 @@ export function defineProvider<
|
|
|
739
2250
|
allowedHosts: config.allowedHosts,
|
|
740
2251
|
stealth: config.stealth,
|
|
741
2252
|
proxy: config.proxy,
|
|
2253
|
+
stt: config.stt,
|
|
742
2254
|
browser: config.browser,
|
|
743
2255
|
auth: config.auth,
|
|
744
2256
|
reviewed: config.reviewed,
|
|
2257
|
+
access: config.access,
|
|
745
2258
|
secrets: config.secrets,
|
|
746
2259
|
credential: config.credential,
|
|
747
2260
|
context: config.context,
|
|
748
2261
|
meta: config.meta,
|
|
749
2262
|
operations: config.operations,
|
|
750
2263
|
healthMonitor: config.healthMonitor,
|
|
2264
|
+
healthJourneys: config.healthJourneys,
|
|
751
2265
|
};
|
|
752
2266
|
}
|