@apifuse/provider-sdk 2.1.0-beta.8 → 2.2.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 (133) hide show
  1. package/AUTHORING.md +134 -0
  2. package/CHANGELOG.md +61 -0
  3. package/README.md +21 -9
  4. package/SUBMISSION.md +10 -11
  5. package/bin/apifuse-pack-check.ts +22 -0
  6. package/bin/apifuse-perf.ts +18 -9
  7. package/bin/apifuse-submit-check.ts +1747 -388
  8. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  9. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  10. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  13. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  14. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  15. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  16. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  17. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  18. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  19. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  20. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  21. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  22. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  23. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  24. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  25. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  26. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  27. package/dist/auth-turn/index.d.ts +195 -0
  28. package/dist/auth-turn/index.js +133 -0
  29. package/dist/auth.d.ts +76 -0
  30. package/dist/auth.js +436 -0
  31. package/dist/ceremonies/index.js +7 -31
  32. package/dist/cli/create.js +45 -30
  33. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  34. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  35. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  36. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  37. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  38. package/dist/cli/templates/provider/README.md.tpl +163 -0
  39. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  40. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  41. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  42. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  43. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  44. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  45. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  46. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  48. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  49. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  50. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  51. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  52. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  53. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  54. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  55. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  56. package/dist/contract.js +1 -0
  57. package/dist/define.d.ts +6 -1
  58. package/dist/define.js +140 -70
  59. package/dist/index.d.ts +3 -2
  60. package/dist/index.js +2 -1
  61. package/dist/lint.d.ts +1 -0
  62. package/dist/lint.js +27 -0
  63. package/dist/provider.d.ts +4 -2
  64. package/dist/provider.js +2 -1
  65. package/dist/runtime/auth-flow.js +2 -0
  66. package/dist/runtime/browser.js +203 -0
  67. package/dist/runtime/http.js +28 -8
  68. package/dist/runtime/stealth.d.ts +5 -2
  69. package/dist/runtime/stealth.js +157 -4
  70. package/dist/server/index.d.ts +4 -0
  71. package/dist/server/index.js +4 -0
  72. package/dist/server/self-test-input-tokens.d.ts +1 -0
  73. package/dist/server/self-test-input-tokens.js +37 -0
  74. package/dist/server/self-test-redaction.d.ts +20 -0
  75. package/dist/server/self-test-redaction.js +70 -0
  76. package/dist/server/self-test-token.d.ts +30 -0
  77. package/dist/server/self-test-token.js +50 -0
  78. package/dist/server/self-test.d.ts +98 -0
  79. package/dist/server/self-test.js +555 -0
  80. package/dist/server/serve.d.ts +6 -0
  81. package/dist/server/serve.js +33 -8
  82. package/dist/testing/run.js +5 -1
  83. package/dist/types.d.ts +152 -0
  84. package/package.json +10 -3
  85. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  86. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  87. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  88. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  89. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  90. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  91. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  92. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  93. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  94. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  95. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  96. package/src/auth-turn/fixtures/valid/form.json +14 -0
  97. package/src/auth-turn/fixtures/valid/message.json +13 -0
  98. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  99. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  100. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  101. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  102. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  103. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  104. package/src/auth-turn/index.ts +177 -0
  105. package/src/auth.ts +786 -0
  106. package/src/ceremonies/index.ts +9 -43
  107. package/src/cli/create.ts +60 -97
  108. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  109. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  110. package/src/cli/templates/provider/README.md.tpl +7 -4
  111. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  112. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  113. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  114. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  115. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  116. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  117. package/src/contract.ts +1 -0
  118. package/src/define.ts +198 -71
  119. package/src/index.ts +16 -0
  120. package/src/lint.ts +33 -0
  121. package/src/provider.ts +27 -0
  122. package/src/runtime/auth-flow.ts +2 -0
  123. package/src/runtime/browser.ts +293 -1
  124. package/src/runtime/http.ts +48 -7
  125. package/src/runtime/stealth.ts +190 -6
  126. package/src/server/index.ts +36 -0
  127. package/src/server/self-test-input-tokens.ts +46 -0
  128. package/src/server/self-test-redaction.ts +97 -0
  129. package/src/server/self-test-token.ts +70 -0
  130. package/src/server/self-test.ts +725 -0
  131. package/src/server/serve.ts +67 -4
  132. package/src/testing/run.ts +9 -1
  133. package/src/types.ts +188 -0
@@ -0,0 +1,725 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import { type Context, Hono } from "hono";
5
+ import { z } from "zod";
6
+ import type {
7
+ HealthCheckAssertionContext,
8
+ HealthCheckCase,
9
+ HealthCheckSuite,
10
+ OperationDefinition,
11
+ ProviderDefinition,
12
+ } from "../types";
13
+ import { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens";
14
+ import { collectSelfTestSensitiveValues, redactSelfTestText } from "./self-test-redaction";
15
+ import {
16
+ DEFAULT_SELF_TEST_PORT,
17
+ PROVIDER_RUNTIME_SELF_TEST_PORT_ENV,
18
+ type SelfTestMasterSecrets,
19
+ verifySelfTestAuthorization,
20
+ } from "./self-test-token";
21
+ import type { OperationConnection } from "./types";
22
+
23
+ export const SELF_TEST_SCHEMA_VERSION = 1 as const;
24
+ export const SELF_TEST_PATH = "/internal/health/self-test";
25
+ export const SELF_TEST_HEALTHZ_PATH = "/healthz";
26
+
27
+ /** Overall wall-clock budget for one self-test request (all selected cases). */
28
+ export const PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV =
29
+ "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_REQUEST_BUDGET_MS";
30
+ export const DEFAULT_SELF_TEST_REQUEST_BUDGET_MS = 120_000;
31
+
32
+ const DEFAULT_CASE_TIMEOUT_MS = 30_000;
33
+ const SELF_TEST_BUSY_RETRY_AFTER_MS = 1_000;
34
+
35
+ export const SelfTestRequestSchema = z.object({
36
+ schemaVersion: z.number().int(),
37
+ requestId: z.string().min(1),
38
+ /** Single-case form (one case per request): both fields required together. */
39
+ operationId: z.string().min(1).optional(),
40
+ caseName: z.string().min(1).optional(),
41
+ /** Batch form: optional selectors; omitted = every declared case. */
42
+ operations: z.array(z.string().min(1)).optional(),
43
+ caseNames: z.array(z.string().min(1)).optional(),
44
+ /** Per-case timeout override; case/suite/provider defaults apply otherwise. */
45
+ timeoutMs: z.number().int().min(1).max(600_000).optional(),
46
+ /** Credential material for requiresConnection cases; never persisted. */
47
+ credentials: z.object({ inputs: z.record(z.string(), z.string()) }).optional(),
48
+ });
49
+
50
+ export type SelfTestRequest = z.infer<typeof SelfTestRequestSchema>;
51
+
52
+ export type SelfTestCaseStatus = "ok" | "degraded" | "failed" | "error" | "skipped";
53
+
54
+ export interface SelfTestCaseResult {
55
+ operationId: string;
56
+ caseName: string;
57
+ status: SelfTestCaseStatus;
58
+ label: string;
59
+ responseTimeMs: number;
60
+ httpStatus?: number;
61
+ assertion?: { passed: boolean; message?: string };
62
+ skipReason?: string;
63
+ error?: { code: string; message: string };
64
+ startedAt: string;
65
+ finishedAt: string;
66
+ }
67
+
68
+ export interface SelfTestResponse {
69
+ schemaVersion: typeof SELF_TEST_SCHEMA_VERSION;
70
+ providerId: string;
71
+ sdkVersion: string;
72
+ planDigest: string;
73
+ /** Present for single-case requests (mirrors results[0]). */
74
+ result?: SelfTestCaseResult;
75
+ results: SelfTestCaseResult[];
76
+ }
77
+
78
+ export type SelfTestOperationInvoke = (args: {
79
+ operationId: string;
80
+ input: unknown;
81
+ connection?: OperationConnection;
82
+ requestId: string;
83
+ }) => Promise<{
84
+ status: number;
85
+ data: unknown;
86
+ meta?: Record<string, unknown>;
87
+ }>;
88
+
89
+ export interface SelfTestAppOptions {
90
+ /** Derived-token verification secrets; without them every self-test route 404s. */
91
+ secrets?: SelfTestMasterSecrets;
92
+ /** In-process invoke bound to the tenant-facing app's /v1 pipeline. */
93
+ invoke: SelfTestOperationInvoke;
94
+ /** Overall request budget; defaults to env / 120s. */
95
+ requestBudgetMs?: number;
96
+ /** Env override for secret collection + budget resolution (tests). */
97
+ env?: Readonly<Record<string, string | undefined>>;
98
+ }
99
+
100
+ function resolveSdkVersion(): string {
101
+ try {
102
+ const packageJsonUrl = new URL("../../package.json", import.meta.url);
103
+ const parsed: unknown = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
104
+ if (
105
+ parsed &&
106
+ typeof parsed === "object" &&
107
+ "version" in parsed &&
108
+ typeof (parsed as { version: unknown }).version === "string"
109
+ ) {
110
+ return (parsed as { version: string }).version;
111
+ }
112
+ } catch {
113
+ // fall through to unknown
114
+ }
115
+ return "unknown";
116
+ }
117
+
118
+ const SDK_VERSION = resolveSdkVersion();
119
+
120
+ type AnyHealthCheckSuite = HealthCheckSuite<unknown, unknown>;
121
+ type AnyHealthCheckCase = HealthCheckCase<unknown, unknown>;
122
+
123
+ function healthCheckSuite(operation: OperationDefinition): AnyHealthCheckSuite | undefined {
124
+ return operation.healthCheck as AnyHealthCheckSuite | undefined;
125
+ }
126
+
127
+ /**
128
+ * Stable sha256 over the provider's declared health plan (operations, case
129
+ * names, timeouts) so the scheduler can detect plan/image skew by comparing
130
+ * the manifest planDigest against the pod-reported one.
131
+ */
132
+ export function computeSelfTestPlanDigest(provider: ProviderDefinition): string {
133
+ const plan = Object.keys(provider.operations)
134
+ .sort()
135
+ .flatMap((operationId) => {
136
+ const operation = provider.operations[operationId];
137
+ const suite = operation ? healthCheckSuite(operation) : undefined;
138
+ if (!suite) return [];
139
+ return [
140
+ {
141
+ operationId,
142
+ interval: suite.interval,
143
+ timeoutMs: suite.timeoutMs ?? null,
144
+ requiresConnection: suite.requiresConnection ?? false,
145
+ cases: suite.cases.map((healthCase) => ({
146
+ name: healthCase.name,
147
+ timeoutMs: healthCase.timeoutMs ?? null,
148
+ degradedThresholdMs: healthCase.degradedThresholdMs ?? null,
149
+ expectedStatus: healthCase.expectedStatus ?? "ok",
150
+ })),
151
+ },
152
+ ];
153
+ });
154
+ const canonical = JSON.stringify({
155
+ schemaVersion: SELF_TEST_SCHEMA_VERSION,
156
+ providerId: provider.id,
157
+ plan,
158
+ });
159
+ return createHash("sha256").update(canonical).digest("hex");
160
+ }
161
+
162
+ /**
163
+ * Fail-closed read-only classification: an operation may self-test only when
164
+ * its own metadata marks it read-only (annotations.readOnly or
165
+ * toolRouter.riskClass "read") and nothing marks it side-effecting. Unclassified
166
+ * operations are refused so the endpoint cannot become a mutation oracle.
167
+ */
168
+ export function isSelfTestReadOnlyOperation(operation: OperationDefinition): boolean {
169
+ const annotations = operation.annotations;
170
+ if (annotations?.readOnly === false) return false;
171
+ if (annotations?.destructive === true) return false;
172
+ const riskClass = operation.toolRouter?.riskClass;
173
+ if (riskClass !== undefined && riskClass !== "read") return false;
174
+ return annotations?.readOnly === true || riskClass === "read";
175
+ }
176
+
177
+ /** Binds the self-test executor to a tenant app's /v1 pipeline in-process. */
178
+ export function createSelfTestInvoke(app: {
179
+ request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
180
+ }): SelfTestOperationInvoke {
181
+ return async ({ operationId, input, connection, requestId }) => {
182
+ const response = await app.request(`/v1/${encodeURIComponent(operationId)}`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify({
186
+ requestId,
187
+ input: input ?? {},
188
+ ...(connection ? { connection } : {}),
189
+ }),
190
+ });
191
+ const text = await response.text();
192
+ let body: unknown = text;
193
+ try {
194
+ body = text.length > 0 ? JSON.parse(text) : undefined;
195
+ } catch {
196
+ // non-JSON transports keep the raw text as data
197
+ }
198
+ if (response.ok && body && typeof body === "object" && !Array.isArray(body) && "data" in body) {
199
+ const envelope = body as { data: unknown; meta?: Record<string, unknown> };
200
+ return {
201
+ status: response.status,
202
+ data: envelope.data,
203
+ ...(envelope.meta ? { meta: envelope.meta } : {}),
204
+ };
205
+ }
206
+ return { status: response.status, data: body };
207
+ };
208
+ }
209
+
210
+ class SelfTestCaseTimeoutError extends Error {
211
+ constructor(timeoutMs: number) {
212
+ super(`Self-test case timed out after ${timeoutMs}ms`);
213
+ this.name = "SelfTestCaseTimeoutError";
214
+ }
215
+ }
216
+
217
+ async function withCaseTimeout<T>(run: () => Promise<T>, timeoutMs: number): Promise<T> {
218
+ let timer: ReturnType<typeof setTimeout> | undefined;
219
+ try {
220
+ return await Promise.race([
221
+ run(),
222
+ new Promise<never>((_, reject) => {
223
+ timer = setTimeout(() => reject(new SelfTestCaseTimeoutError(timeoutMs)), timeoutMs);
224
+ }),
225
+ ]);
226
+ } finally {
227
+ if (timer !== undefined) clearTimeout(timer);
228
+ }
229
+ }
230
+
231
+ function objectProperty(value: unknown, key: string): unknown {
232
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
233
+ return undefined;
234
+ }
235
+ for (const [entryKey, entryValue] of Object.entries(value)) {
236
+ if (entryKey === key) return entryValue;
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ function upstreamErrorCode(body: unknown): string | undefined {
242
+ const error = objectProperty(body, "error");
243
+ const code = objectProperty(error, "code");
244
+ return typeof code === "string" ? code : undefined;
245
+ }
246
+
247
+ function upstreamErrorMessage(body: unknown): string | undefined {
248
+ const error = objectProperty(body, "error");
249
+ const message = objectProperty(error, "message");
250
+ return typeof message === "string" ? message : undefined;
251
+ }
252
+
253
+ interface SelfTestExecutionContext {
254
+ provider: ProviderDefinition;
255
+ invoke: SelfTestOperationInvoke;
256
+ requestId: string;
257
+ credentials?: Readonly<Record<string, string>>;
258
+ requestTimeoutMs?: number;
259
+ sensitiveValues: readonly string[];
260
+ }
261
+
262
+ function resolveCaseTimeoutMs(
263
+ execution: SelfTestExecutionContext,
264
+ suite: AnyHealthCheckSuite,
265
+ healthCase: AnyHealthCheckCase,
266
+ ): number {
267
+ const providerDefault = (execution.provider.healthProbe ?? execution.provider.healthMonitor)
268
+ ?.defaultProbeTimeoutMs;
269
+ return (
270
+ execution.requestTimeoutMs ??
271
+ healthCase.timeoutMs ??
272
+ suite.timeoutMs ??
273
+ providerDefault ??
274
+ DEFAULT_CASE_TIMEOUT_MS
275
+ );
276
+ }
277
+
278
+ function buildSelfTestConnection(
279
+ execution: SelfTestExecutionContext,
280
+ operationId: string,
281
+ suite: AnyHealthCheckSuite,
282
+ ): { connection?: OperationConnection } | { skipReason: string } {
283
+ if (!suite.requiresConnection) return {};
284
+ const inputs = execution.credentials ?? {};
285
+ const declaredFields = Object.keys(
286
+ (execution.provider.healthProbe ?? execution.provider.healthMonitor)?.credentialInputs ?? {},
287
+ );
288
+ for (const field of declaredFields) {
289
+ if (!inputs[field]) {
290
+ return { skipReason: `credential_missing:${field}` };
291
+ }
292
+ }
293
+ if (declaredFields.length === 0 && Object.keys(inputs).length === 0) {
294
+ return { skipReason: "credential_missing:credentials" };
295
+ }
296
+ return {
297
+ connection: {
298
+ id: `self-test-${execution.requestId}`,
299
+ mode: "credentials",
300
+ secrets: { ...inputs },
301
+ metadata: { purpose: "provider-self-test", operationId },
302
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
303
+ },
304
+ };
305
+ }
306
+
307
+ async function executeSelfTestCase(
308
+ execution: SelfTestExecutionContext,
309
+ operationId: string,
310
+ suite: AnyHealthCheckSuite,
311
+ healthCase: AnyHealthCheckCase,
312
+ ): Promise<SelfTestCaseResult> {
313
+ const { provider, invoke, sensitiveValues } = execution;
314
+ const redact = (text: string) => redactSelfTestText(text, sensitiveValues);
315
+ const startedAt = new Date().toISOString();
316
+ const startedAtMs = performance.now();
317
+ const finish = (
318
+ partial: Omit<
319
+ SelfTestCaseResult,
320
+ "operationId" | "caseName" | "startedAt" | "finishedAt" | "responseTimeMs"
321
+ > & { responseTimeMs?: number },
322
+ ): SelfTestCaseResult => ({
323
+ operationId,
324
+ caseName: healthCase.name,
325
+ startedAt,
326
+ finishedAt: new Date().toISOString(),
327
+ responseTimeMs:
328
+ partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
329
+ ...partial,
330
+ });
331
+ const defaultLabel = redact(healthCase.description ?? healthCase.name);
332
+
333
+ if (healthCase.enabled && healthCase.enabled() === false) {
334
+ return finish({
335
+ status: "skipped",
336
+ label: defaultLabel,
337
+ skipReason: "disabled",
338
+ });
339
+ }
340
+
341
+ const connectionResolution = buildSelfTestConnection(execution, operationId, suite);
342
+ if ("skipReason" in connectionResolution) {
343
+ return finish({
344
+ status: "skipped",
345
+ label: defaultLabel,
346
+ skipReason: connectionResolution.skipReason,
347
+ });
348
+ }
349
+ const connection = connectionResolution.connection;
350
+
351
+ const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
352
+ try {
353
+ return await withCaseTimeout(async () => {
354
+ const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
355
+ const preparedInput = healthCase.prepareInput
356
+ ? await healthCase.prepareInput({
357
+ providerId: provider.id,
358
+ operationId,
359
+ input: resolvedInput,
360
+ ...(connection ? { connectionId: connection.id } : {}),
361
+ gateway: {
362
+ execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
363
+ if (foreignProviderId !== provider.id) {
364
+ throw new Error(
365
+ `Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`,
366
+ );
367
+ }
368
+ const startedGatewayMs = performance.now();
369
+ const executed = await invoke({
370
+ operationId: gatewayOperationId,
371
+ input: gatewayInput,
372
+ connection,
373
+ requestId: `${execution.requestId}-prepare-${randomUUID()}`,
374
+ });
375
+ return {
376
+ status: executed.status,
377
+ duration: performance.now() - startedGatewayMs,
378
+ data: executed.data,
379
+ meta: executed.meta,
380
+ };
381
+ },
382
+ },
383
+ })
384
+ : resolvedInput;
385
+
386
+ const executed = await invoke({
387
+ operationId,
388
+ input: preparedInput,
389
+ connection,
390
+ requestId: `${execution.requestId}-${randomUUID()}`,
391
+ });
392
+ const durationMs = performance.now() - startedAtMs;
393
+
394
+ if (executed.status < 200 || executed.status >= 300) {
395
+ return finish({
396
+ status: "failed",
397
+ label: defaultLabel,
398
+ httpStatus: executed.status,
399
+ error: {
400
+ code: upstreamErrorCode(executed.data) ?? "operation_failed",
401
+ message: redact(
402
+ upstreamErrorMessage(executed.data) ??
403
+ `Operation invocation failed with status ${executed.status}`,
404
+ ),
405
+ },
406
+ });
407
+ }
408
+
409
+ const assertionContext: HealthCheckAssertionContext = {
410
+ status: executed.status,
411
+ data: executed.data,
412
+ durationMs,
413
+ ...(executed.meta ? { meta: executed.meta } : {}),
414
+ };
415
+ let assertionResult: unknown;
416
+ try {
417
+ assertionResult = await healthCase.assertions(assertionContext);
418
+ } catch (assertionError) {
419
+ return finish({
420
+ status: "failed",
421
+ label: defaultLabel,
422
+ httpStatus: executed.status,
423
+ assertion: {
424
+ passed: false,
425
+ message: redact(
426
+ assertionError instanceof Error ? assertionError.message : String(assertionError),
427
+ ),
428
+ },
429
+ });
430
+ }
431
+ const statusValue = objectProperty(assertionResult, "status");
432
+ const overrideStatus =
433
+ statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
434
+ const labelValue = objectProperty(assertionResult, "label");
435
+ const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
436
+ return finish({
437
+ status: overrideStatus ?? "ok",
438
+ label: overrideLabel ?? defaultLabel,
439
+ httpStatus: executed.status,
440
+ assertion: { passed: true },
441
+ });
442
+ }, timeoutMs);
443
+ } catch (error) {
444
+ if (error instanceof SelfTestCaseTimeoutError) {
445
+ return finish({
446
+ status: "error",
447
+ label: defaultLabel,
448
+ error: { code: "self_test_timeout", message: redact(error.message) },
449
+ });
450
+ }
451
+ return finish({
452
+ status: "error",
453
+ label: defaultLabel,
454
+ error: {
455
+ code: "self_test_execution_error",
456
+ message: redact(error instanceof Error ? error.message : String(error)),
457
+ },
458
+ });
459
+ }
460
+ }
461
+
462
+ interface SelectedCase {
463
+ operationId: string;
464
+ operation: OperationDefinition;
465
+ suite: AnyHealthCheckSuite;
466
+ healthCase: AnyHealthCheckCase;
467
+ }
468
+
469
+ type CaseSelection =
470
+ | { cases: SelectedCase[] }
471
+ | { errorStatus: 403 | 422; errorCode: string; errorMessage: string };
472
+
473
+ function selectCases(provider: ProviderDefinition, request: SelfTestRequest): CaseSelection {
474
+ const singleCase = request.operationId !== undefined && request.caseName !== undefined;
475
+ if (singleCase) {
476
+ const operationId = request.operationId as string;
477
+ const operation = provider.operations[operationId];
478
+ const suite = operation ? healthCheckSuite(operation) : undefined;
479
+ const healthCase = suite?.cases.find((candidate) => candidate.name === request.caseName);
480
+ if (!operation || !suite || !healthCase) {
481
+ return {
482
+ errorStatus: 422,
483
+ errorCode: "case_not_found",
484
+ errorMessage: `Case "${request.caseName}" not found for operation "${operationId}".`,
485
+ };
486
+ }
487
+ if (!isSelfTestReadOnlyOperation(operation)) {
488
+ return {
489
+ errorStatus: 403,
490
+ errorCode: "operation_not_read_only",
491
+ errorMessage: `Operation "${operationId}" is not classified read-only; self-test refuses to execute it.`,
492
+ };
493
+ }
494
+ return { cases: [{ operationId, operation, suite, healthCase }] };
495
+ }
496
+
497
+ const operationFilter = request.operations ? new Set(request.operations) : undefined;
498
+ const caseNameFilter = request.caseNames ? new Set(request.caseNames) : undefined;
499
+ if (operationFilter) {
500
+ for (const operationId of operationFilter) {
501
+ const operation = provider.operations[operationId];
502
+ if (!operation || !healthCheckSuite(operation)) {
503
+ return {
504
+ errorStatus: 422,
505
+ errorCode: "case_not_found",
506
+ errorMessage: `Operation "${operationId}" has no declared healthCheck cases.`,
507
+ };
508
+ }
509
+ }
510
+ }
511
+ const cases: SelectedCase[] = [];
512
+ for (const operationId of Object.keys(provider.operations).sort()) {
513
+ if (operationFilter && !operationFilter.has(operationId)) continue;
514
+ const operation = provider.operations[operationId];
515
+ const suite = operation ? healthCheckSuite(operation) : undefined;
516
+ if (!operation || !suite) continue;
517
+ for (const healthCase of suite.cases) {
518
+ if (caseNameFilter && !caseNameFilter.has(healthCase.name)) continue;
519
+ cases.push({ operationId, operation, suite, healthCase });
520
+ }
521
+ }
522
+ if (cases.length === 0) {
523
+ return {
524
+ errorStatus: 422,
525
+ errorCode: "case_not_found",
526
+ errorMessage: "No declared healthCheck cases matched the selection.",
527
+ };
528
+ }
529
+ return { cases };
530
+ }
531
+
532
+ function resolveRequestBudgetMs(options: SelfTestAppOptions): number {
533
+ if (options.requestBudgetMs !== undefined) return options.requestBudgetMs;
534
+ const env = options.env ?? process.env;
535
+ const raw = env[PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV];
536
+ const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
537
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SELF_TEST_REQUEST_BUDGET_MS;
538
+ }
539
+
540
+ export function resolveSelfTestPort(
541
+ env: Readonly<Record<string, string | undefined>> = process.env,
542
+ ): number {
543
+ const raw = env[PROVIDER_RUNTIME_SELF_TEST_PORT_ENV];
544
+ const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
545
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535
546
+ ? parsed
547
+ : DEFAULT_SELF_TEST_PORT;
548
+ }
549
+
550
+ /**
551
+ * Builds the internal self-test Hono app. This app is served on a SEPARATE
552
+ * listener (default :3001) that the tenant-facing gateway never dials; when no
553
+ * master secret is resolvable the self-test route responds 404 (safe default
554
+ * off) while `GET /healthz` stays available for liveness.
555
+ */
556
+ export function createSelfTestApp(provider: ProviderDefinition, options: SelfTestAppOptions): Hono {
557
+ const app = new Hono();
558
+ const planDigest = computeSelfTestPlanDigest(provider);
559
+ const requestBudgetMs = resolveRequestBudgetMs(options);
560
+ let busy = false;
561
+
562
+ app.notFound((c) => c.json({ error: { code: "not_found", message: "Not found" } }, 404));
563
+
564
+ app.get(SELF_TEST_HEALTHZ_PATH, (c) => c.json({ ok: true }));
565
+
566
+ const handleSelfTest = async (c: Context) => {
567
+ if (!options.secrets) {
568
+ return c.json({ error: { code: "not_found", message: "Not found" } }, 404);
569
+ }
570
+ const authorized = verifySelfTestAuthorization(
571
+ c.req.raw.headers.get("authorization") ?? undefined,
572
+ provider.id,
573
+ options.secrets,
574
+ );
575
+ if (!authorized) {
576
+ return c.json({ error: { code: "unauthorized", message: "Unauthorized" } }, 401);
577
+ }
578
+
579
+ const rawBody: unknown = await c.req.raw
580
+ .clone()
581
+ .json()
582
+ .catch(() => undefined);
583
+ const schemaVersion = objectProperty(rawBody, "schemaVersion");
584
+ if (schemaVersion !== SELF_TEST_SCHEMA_VERSION) {
585
+ return c.json(
586
+ {
587
+ error: {
588
+ code: "unsupported_schema_version",
589
+ message: `Unsupported self-test schemaVersion; supported: [${SELF_TEST_SCHEMA_VERSION}].`,
590
+ supported: [SELF_TEST_SCHEMA_VERSION],
591
+ },
592
+ },
593
+ 400,
594
+ );
595
+ }
596
+ const parsed = SelfTestRequestSchema.safeParse(rawBody);
597
+ if (!parsed.success) {
598
+ return c.json(
599
+ {
600
+ error: {
601
+ code: "invalid_request",
602
+ message: "Invalid self-test request body",
603
+ },
604
+ },
605
+ 400,
606
+ );
607
+ }
608
+ const request = parsed.data;
609
+ if ((request.operationId === undefined) !== (request.caseName === undefined)) {
610
+ return c.json(
611
+ {
612
+ error: {
613
+ code: "invalid_request",
614
+ message: "operationId and caseName must be provided together for single-case requests.",
615
+ },
616
+ },
617
+ 400,
618
+ );
619
+ }
620
+
621
+ const selection = selectCases(provider, request);
622
+ if ("errorStatus" in selection) {
623
+ return c.json(
624
+ {
625
+ error: {
626
+ code: selection.errorCode,
627
+ message: selection.errorMessage,
628
+ },
629
+ },
630
+ selection.errorStatus,
631
+ );
632
+ }
633
+
634
+ if (busy) {
635
+ return c.json(
636
+ {
637
+ error: {
638
+ code: "self_test_busy",
639
+ message: "A self-test request is already executing.",
640
+ },
641
+ retryAfterMs: SELF_TEST_BUSY_RETRY_AFTER_MS,
642
+ },
643
+ 409,
644
+ );
645
+ }
646
+ busy = true;
647
+ try {
648
+ const execution: SelfTestExecutionContext = {
649
+ provider,
650
+ invoke: options.invoke,
651
+ requestId: request.requestId,
652
+ credentials: request.credentials?.inputs,
653
+ requestTimeoutMs: request.timeoutMs,
654
+ sensitiveValues: collectSelfTestSensitiveValues(provider, {
655
+ env: options.env,
656
+ credentialInputs: request.credentials?.inputs,
657
+ }),
658
+ };
659
+ const deadline = performance.now() + requestBudgetMs;
660
+ const results: SelfTestCaseResult[] = [];
661
+ // Sequential execution (parallelism 1): self-tests run on serving pods
662
+ // and must never compete with themselves for upstream quota.
663
+ for (const selected of selection.cases) {
664
+ if (performance.now() >= deadline) {
665
+ const now = new Date().toISOString();
666
+ results.push({
667
+ operationId: selected.operationId,
668
+ caseName: selected.healthCase.name,
669
+ status: "skipped",
670
+ label: selected.healthCase.name,
671
+ responseTimeMs: 0,
672
+ skipReason: "budget_exhausted",
673
+ startedAt: now,
674
+ finishedAt: now,
675
+ });
676
+ continue;
677
+ }
678
+ if (!isSelfTestReadOnlyOperation(selected.operation)) {
679
+ const now = new Date().toISOString();
680
+ results.push({
681
+ operationId: selected.operationId,
682
+ caseName: selected.healthCase.name,
683
+ status: "error",
684
+ label: selected.healthCase.name,
685
+ responseTimeMs: 0,
686
+ error: {
687
+ code: "operation_not_read_only",
688
+ message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
689
+ },
690
+ startedAt: now,
691
+ finishedAt: now,
692
+ });
693
+ continue;
694
+ }
695
+ results.push(
696
+ await executeSelfTestCase(
697
+ execution,
698
+ selected.operationId,
699
+ selected.suite,
700
+ selected.healthCase,
701
+ ),
702
+ );
703
+ }
704
+ const singleCase = request.operationId !== undefined && request.caseName !== undefined;
705
+ const response: SelfTestResponse = {
706
+ schemaVersion: SELF_TEST_SCHEMA_VERSION,
707
+ providerId: provider.id,
708
+ sdkVersion: SDK_VERSION,
709
+ planDigest,
710
+ ...(singleCase && results[0] ? { result: results[0] } : {}),
711
+ results,
712
+ };
713
+ return c.json(response, 200);
714
+ } finally {
715
+ busy = false;
716
+ }
717
+ };
718
+
719
+ app.post(SELF_TEST_PATH, handleSelfTest);
720
+ // Transitional alias so schedulers can address the endpoint by its short
721
+ // path; the canonical path is /internal/health/self-test.
722
+ app.post("/self-test", handleSelfTest);
723
+
724
+ return app;
725
+ }