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

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 (263) hide show
  1. package/AUTHORING.md +240 -0
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +26 -10
  4. package/SUBMISSION.md +11 -12
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +36 -0
  9. package/bin/apifuse-pack-smoke.ts +22 -81
  10. package/bin/apifuse-pack-types.ts +266 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +53 -70
  13. package/bin/apifuse-submit-check.ts +2177 -353
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  20. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  21. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  22. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  23. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  24. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  25. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  26. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  27. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  28. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  29. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  30. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  31. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  32. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  33. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  34. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  35. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  36. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  37. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  38. package/dist/auth-turn/index.d.ts +195 -0
  39. package/dist/auth-turn/index.js +133 -0
  40. package/dist/auth.d.ts +76 -0
  41. package/dist/auth.js +427 -0
  42. package/dist/ceremonies/index.d.ts +1 -1
  43. package/dist/ceremonies/index.js +14 -48
  44. package/dist/cli/commands.d.ts +1 -1
  45. package/dist/cli/commands.js +8 -0
  46. package/dist/cli/create.d.ts +3 -0
  47. package/dist/cli/create.js +47 -33
  48. package/dist/cli/prompt-assets.d.ts +80 -0
  49. package/dist/cli/prompt-assets.js +743 -0
  50. package/dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  51. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  52. package/dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  53. package/dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  54. package/dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  55. package/dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  56. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  57. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  58. package/dist/cli/templates/provider/AGENTS.md.tpl +96 -0
  59. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  60. package/dist/cli/templates/provider/README.md.tpl +163 -0
  61. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  62. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  63. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  64. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  65. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  66. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  67. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  68. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  69. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  70. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  71. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  72. package/dist/config/loader.d.ts +149 -8
  73. package/dist/config/loader.js +378 -89
  74. package/dist/contract-serialization.d.ts +2 -2
  75. package/dist/contract-serialization.js +3 -6
  76. package/dist/contract-types.d.ts +2 -2
  77. package/dist/contract.d.ts +3 -3
  78. package/dist/contract.js +5 -6
  79. package/dist/define.d.ts +13 -1
  80. package/dist/define.js +245 -178
  81. package/dist/dev.d.ts +1 -1
  82. package/dist/dev.js +1 -1
  83. package/dist/errors.d.ts +4 -1
  84. package/dist/errors.js +48 -0
  85. package/dist/i18n/catalog.d.ts +2 -2
  86. package/dist/i18n/catalog.js +4 -10
  87. package/dist/i18n/index.d.ts +2 -2
  88. package/dist/i18n/index.js +2 -2
  89. package/dist/i18n/keys.d.ts +2 -2
  90. package/dist/index.d.ts +44 -41
  91. package/dist/index.js +39 -36
  92. package/dist/lint.d.ts +1 -0
  93. package/dist/lint.js +35 -15
  94. package/dist/provider.d.ts +11 -9
  95. package/dist/provider.js +9 -8
  96. package/dist/public-schema-field-lint.d.ts +1 -1
  97. package/dist/recipes/gov-api.js +1 -1
  98. package/dist/runtime/auth-flow.d.ts +1 -1
  99. package/dist/runtime/auth-flow.js +4 -2
  100. package/dist/runtime/browser.d.ts +1 -1
  101. package/dist/runtime/browser.js +214 -25
  102. package/dist/runtime/cache.d.ts +1 -1
  103. package/dist/runtime/cache.js +4 -8
  104. package/dist/runtime/choice.d.ts +1 -1
  105. package/dist/runtime/choice.js +31 -35
  106. package/dist/runtime/credential.d.ts +1 -1
  107. package/dist/runtime/credential.js +1 -1
  108. package/dist/runtime/env.d.ts +1 -1
  109. package/dist/runtime/executor.d.ts +1 -1
  110. package/dist/runtime/executor.js +15 -3
  111. package/dist/runtime/http.d.ts +2 -2
  112. package/dist/runtime/http.js +160 -344
  113. package/dist/runtime/insights.d.ts +1 -1
  114. package/dist/runtime/insights.js +6 -13
  115. package/dist/runtime/instrumentation.d.ts +2 -2
  116. package/dist/runtime/instrumentation.js +56 -19
  117. package/dist/runtime/keyring.js +1 -1
  118. package/dist/runtime/namespace.js +1 -1
  119. package/dist/runtime/otlp.d.ts +1 -1
  120. package/dist/runtime/perf.d.ts +1 -1
  121. package/dist/runtime/provider.d.ts +1 -1
  122. package/dist/runtime/provider.js +1 -2
  123. package/dist/runtime/proxy-errors.d.ts +1 -1
  124. package/dist/runtime/proxy-errors.js +9 -7
  125. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  126. package/dist/runtime/proxy-nodemaven.js +128 -0
  127. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  128. package/dist/runtime/proxy-retry-policy.js +326 -0
  129. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  130. package/dist/runtime/proxy-telemetry.js +55 -52
  131. package/dist/runtime/redis.d.ts +1 -1
  132. package/dist/runtime/redis.js +2 -4
  133. package/dist/runtime/request-options.d.ts +1 -1
  134. package/dist/runtime/secrets.d.ts +27 -0
  135. package/dist/runtime/secrets.js +51 -0
  136. package/dist/runtime/state.d.ts +2 -2
  137. package/dist/runtime/state.js +15 -4
  138. package/dist/runtime/stealth.d.ts +7 -4
  139. package/dist/runtime/stealth.js +257 -215
  140. package/dist/runtime/stt.d.ts +1 -1
  141. package/dist/runtime/stt.js +11 -15
  142. package/dist/runtime/trace.d.ts +2 -2
  143. package/dist/runtime/trace.js +2 -4
  144. package/dist/runtime/waterfall.d.ts +1 -1
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +7 -15
  147. package/dist/serve.d.ts +1 -1
  148. package/dist/serve.js +1 -1
  149. package/dist/server/index.d.ts +7 -3
  150. package/dist/server/index.js +6 -2
  151. package/dist/server/self-test-input-tokens.d.ts +1 -0
  152. package/dist/server/self-test-input-tokens.js +37 -0
  153. package/dist/server/self-test-redaction.d.ts +20 -0
  154. package/dist/server/self-test-redaction.js +70 -0
  155. package/dist/server/self-test-token.d.ts +30 -0
  156. package/dist/server/self-test-token.js +50 -0
  157. package/dist/server/self-test.d.ts +199 -0
  158. package/dist/server/self-test.js +1113 -0
  159. package/dist/server/serve.d.ts +14 -3
  160. package/dist/server/serve.js +135 -64
  161. package/dist/server/types.d.ts +10 -9
  162. package/dist/server/types.js +3 -7
  163. package/dist/stealth/profiles.d.ts +1 -1
  164. package/dist/stealth/profiles.js +5 -14
  165. package/dist/stream.d.ts +1 -1
  166. package/dist/testing/index.d.ts +2 -2
  167. package/dist/testing/index.js +2 -2
  168. package/dist/testing/run.d.ts +1 -1
  169. package/dist/testing/run.js +12 -15
  170. package/dist/types.d.ts +237 -1
  171. package/dist/user-input.d.ts +30 -0
  172. package/dist/user-input.js +66 -0
  173. package/package.json +16 -5
  174. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  175. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  176. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  177. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  178. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  179. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  180. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  181. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  182. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  183. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  184. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  185. package/src/auth-turn/fixtures/valid/form.json +14 -0
  186. package/src/auth-turn/fixtures/valid/message.json +13 -0
  187. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  188. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  189. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  190. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  191. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  192. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  193. package/src/auth-turn/index.ts +177 -0
  194. package/src/auth.ts +728 -0
  195. package/src/ceremonies/index.ts +33 -121
  196. package/src/cli/commands.ts +10 -0
  197. package/src/cli/create.ts +69 -99
  198. package/src/cli/prompt-assets.ts +865 -0
  199. package/src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  200. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  201. package/src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  202. package/src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  203. package/src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  204. package/src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  205. package/src/cli/templates/provider/AGENTS.md.tpl +96 -0
  206. package/src/cli/templates/provider/README.md.tpl +7 -4
  207. package/src/config/loader.ts +543 -208
  208. package/src/contract-serialization.ts +5 -11
  209. package/src/contract-types.ts +2 -2
  210. package/src/contract.ts +13 -28
  211. package/src/define.ts +397 -528
  212. package/src/dev.ts +4 -9
  213. package/src/errors.ts +58 -5
  214. package/src/i18n/catalog.ts +10 -32
  215. package/src/i18n/index.ts +2 -2
  216. package/src/i18n/keys.ts +5 -11
  217. package/src/index.ts +64 -41
  218. package/src/lint.ts +122 -159
  219. package/src/provider.ts +40 -9
  220. package/src/public-schema-field-lint.ts +7 -33
  221. package/src/recipes/gov-api.ts +2 -5
  222. package/src/runtime/auth-flow.ts +6 -6
  223. package/src/runtime/browser.ts +320 -151
  224. package/src/runtime/cache.ts +20 -67
  225. package/src/runtime/choice.ts +79 -132
  226. package/src/runtime/credential.ts +2 -2
  227. package/src/runtime/env.ts +1 -1
  228. package/src/runtime/executor.ts +23 -20
  229. package/src/runtime/http.ts +216 -539
  230. package/src/runtime/insights.ts +15 -53
  231. package/src/runtime/instrumentation.ts +78 -65
  232. package/src/runtime/keyring.ts +7 -19
  233. package/src/runtime/namespace.ts +2 -7
  234. package/src/runtime/otlp.ts +12 -23
  235. package/src/runtime/perf.ts +1 -1
  236. package/src/runtime/provider.ts +4 -9
  237. package/src/runtime/proxy-errors.ts +29 -42
  238. package/src/runtime/proxy-nodemaven.ts +178 -0
  239. package/src/runtime/proxy-retry-policy.ts +469 -0
  240. package/src/runtime/proxy-telemetry.ts +79 -77
  241. package/src/runtime/redis.ts +4 -12
  242. package/src/runtime/request-options.ts +4 -13
  243. package/src/runtime/secrets.ts +64 -0
  244. package/src/runtime/state.ts +41 -110
  245. package/src/runtime/stealth.ts +331 -369
  246. package/src/runtime/stt.ts +38 -94
  247. package/src/runtime/trace.ts +14 -44
  248. package/src/runtime/waterfall.ts +5 -18
  249. package/src/schema.ts +23 -84
  250. package/src/serve.ts +1 -1
  251. package/src/server/index.ts +44 -3
  252. package/src/server/self-test-input-tokens.ts +46 -0
  253. package/src/server/self-test-redaction.ts +97 -0
  254. package/src/server/self-test-token.ts +70 -0
  255. package/src/server/self-test.ts +1450 -0
  256. package/src/server/serve.ts +206 -216
  257. package/src/server/types.ts +7 -19
  258. package/src/stealth/profiles.ts +10 -26
  259. package/src/stream.ts +8 -19
  260. package/src/testing/index.ts +2 -2
  261. package/src/testing/run.ts +24 -64
  262. package/src/types.ts +274 -1
  263. package/src/user-input.ts +118 -0
@@ -0,0 +1,1113 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { TURN_KINDS } from "../auth-turn/index.js";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
7
+ import { collectSelfTestSensitiveValues, redactSelfTestText } from "./self-test-redaction.js";
8
+ import { DEFAULT_SELF_TEST_PORT, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, verifySelfTestAuthorization, } from "./self-test-token.js";
9
+ export const SELF_TEST_SCHEMA_VERSION = 1;
10
+ export const SELF_TEST_PATH = "/internal/health/self-test";
11
+ export const SELF_TEST_HEALTHZ_PATH = "/healthz";
12
+ /** Overall wall-clock budget for one self-test request (all selected cases). */
13
+ export const PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV = "APIFUSE__PROVIDER_RUNTIME__SELF_TEST_REQUEST_BUDGET_MS";
14
+ export const DEFAULT_SELF_TEST_REQUEST_BUDGET_MS = 120_000;
15
+ const DEFAULT_CASE_TIMEOUT_MS = 30_000;
16
+ const SELF_TEST_BUSY_RETRY_AFTER_MS = 1_000;
17
+ export const SelfTestRequestSchema = z.object({
18
+ schemaVersion: z.number().int(),
19
+ requestId: z.string().min(1),
20
+ /** Single-case form (one case per request): both fields required together. */
21
+ operationId: z.string().min(1).optional(),
22
+ caseName: z.string().min(1).optional(),
23
+ /** Batch form: optional selectors; omitted = every declared case. */
24
+ operations: z.array(z.string().min(1)).optional(),
25
+ caseNames: z.array(z.string().min(1)).optional(),
26
+ /** Per-case timeout override; case/suite/provider defaults apply otherwise. */
27
+ timeoutMs: z.number().int().min(1).max(600_000).optional(),
28
+ /** Credential material for requiresConnection cases; never persisted. */
29
+ credentials: z.object({ inputs: z.record(z.string(), z.string()) }).optional(),
30
+ });
31
+ /**
32
+ * Skip reason reported when a declared auth flow does not complete in a single
33
+ * continue (OTP, retry loop). Cross-repo contract: the health-monitor maps
34
+ * this exact string to `self_test_incapable`; never vary it.
35
+ */
36
+ export const SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON = "auth_flow_multi_turn";
37
+ /**
38
+ * A `retry` turn after credential submission: the flow REJECTED the
39
+ * configured inputs (bad password, exchange failure). Distinct from the
40
+ * multi-turn gap so monitoring surfaces it as a real credential outage, and
41
+ * memoized like multi-turn so the probe does not re-submit rejected
42
+ * credentials every cycle (lockout safety).
43
+ */
44
+ export const SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON = "auth_flow_rejected";
45
+ /**
46
+ * Known interactive turn kinds that justify the memoized multi-turn skip —
47
+ * they mean a human must participate (OTP, challenge, redirect, …).
48
+ * `retry` is deliberately excluded: after a credential submission it means
49
+ * rejection, not interaction (see SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON).
50
+ * Kinds outside TURN_KINDS entirely are treated as flow errors.
51
+ */
52
+ /**
53
+ * Post-submission /auth/continue statuses that mean the flow REJECTED the
54
+ * credentials (thrown AuthError -> 401, forbidden -> 403): memoized as
55
+ * `auth_flow_rejected`. Deliberately NOT 400 — the auth route maps generic
56
+ * ProviderErrors and Zod request errors there, which are often transient or
57
+ * fixable and must stay uncached retries (like 408/429/5xx).
58
+ */
59
+ const AUTH_REJECTION_HTTP_STATUSES = new Set([401, 403]);
60
+ const INTERACTIVE_TURN_KIND_SET = new Set(TURN_KINDS.filter((descriptor) => descriptor.rendering !== "terminal" && descriptor.kind !== "retry").map((descriptor) => descriptor.kind));
61
+ function resolveSdkVersion() {
62
+ try {
63
+ const packageJsonUrl = new URL("../../package.json", import.meta.url);
64
+ const parsed = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
65
+ if (parsed &&
66
+ typeof parsed === "object" &&
67
+ "version" in parsed &&
68
+ typeof parsed.version === "string") {
69
+ return parsed.version;
70
+ }
71
+ }
72
+ catch {
73
+ // fall through to unknown
74
+ }
75
+ return "unknown";
76
+ }
77
+ const SDK_VERSION = resolveSdkVersion();
78
+ function healthCheckSuite(operation) {
79
+ return operation.healthCheck;
80
+ }
81
+ /**
82
+ * Stable sha256 over the provider's declared health plan (operations, case
83
+ * names, timeouts) so the scheduler can detect plan/image skew by comparing
84
+ * the manifest planDigest against the pod-reported one.
85
+ */
86
+ export function computeSelfTestPlanDigest(provider) {
87
+ const plan = Object.keys(provider.operations)
88
+ .sort()
89
+ .flatMap((operationId) => {
90
+ const operation = provider.operations[operationId];
91
+ const suite = operation ? healthCheckSuite(operation) : undefined;
92
+ if (!suite)
93
+ return [];
94
+ return [
95
+ {
96
+ operationId,
97
+ interval: suite.interval,
98
+ timeoutMs: suite.timeoutMs ?? null,
99
+ requiresConnection: suite.requiresConnection ?? false,
100
+ cases: suite.cases.map((healthCase) => ({
101
+ name: healthCase.name,
102
+ timeoutMs: healthCase.timeoutMs ?? null,
103
+ degradedThresholdMs: healthCase.degradedThresholdMs ?? null,
104
+ expectedStatus: healthCase.expectedStatus ?? "ok",
105
+ })),
106
+ },
107
+ ];
108
+ });
109
+ const canonical = JSON.stringify({
110
+ schemaVersion: SELF_TEST_SCHEMA_VERSION,
111
+ providerId: provider.id,
112
+ plan,
113
+ });
114
+ return createHash("sha256").update(canonical).digest("hex");
115
+ }
116
+ /**
117
+ * Fail-closed read-only classification: an operation may self-test only when
118
+ * its own metadata marks it read-only (annotations.readOnly or
119
+ * toolRouter.riskClass "read") and nothing marks it side-effecting. Unclassified
120
+ * operations are refused so the endpoint cannot become a mutation oracle.
121
+ */
122
+ export function isSelfTestReadOnlyOperation(operation) {
123
+ const annotations = operation.annotations;
124
+ if (annotations?.readOnly === false)
125
+ return false;
126
+ if (annotations?.destructive === true)
127
+ return false;
128
+ const riskClass = operation.toolRouter?.riskClass;
129
+ if (riskClass !== undefined && riskClass !== "read")
130
+ return false;
131
+ return annotations?.readOnly === true || riskClass === "read";
132
+ }
133
+ /** Binds the self-test executor to a tenant app's /v1 pipeline in-process. */
134
+ export function createSelfTestInvoke(app) {
135
+ return async ({ operationId, input, connection, requestId }) => {
136
+ const response = await app.request(`/v1/${encodeURIComponent(operationId)}`, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify({
140
+ requestId,
141
+ input: input ?? {},
142
+ ...(connection ? { connection } : {}),
143
+ }),
144
+ });
145
+ const text = await response.text();
146
+ let body = text;
147
+ try {
148
+ body = text.length > 0 ? JSON.parse(text) : undefined;
149
+ }
150
+ catch {
151
+ // non-JSON transports keep the raw text as data
152
+ }
153
+ if (response.ok && body && typeof body === "object" && !Array.isArray(body) && "data" in body) {
154
+ const envelope = body;
155
+ return {
156
+ status: response.status,
157
+ data: envelope.data,
158
+ ...(envelope.meta ? { meta: envelope.meta } : {}),
159
+ };
160
+ }
161
+ return { status: response.status, data: body };
162
+ };
163
+ }
164
+ /** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
165
+ export function createSelfTestAuthFlowInvoke(app) {
166
+ return async ({ route, requestId, flowId, connectionId, externalRef, input, context }) => {
167
+ const response = await app.request(`/auth/${route}`, {
168
+ method: "POST",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify({
171
+ requestId,
172
+ flowId,
173
+ ...(connectionId ? { connectionId } : {}),
174
+ ...(externalRef ? { externalRef } : {}),
175
+ ...(input ? { input } : {}),
176
+ ...(context ? { context } : {}),
177
+ }),
178
+ });
179
+ const text = await response.text();
180
+ let body = text;
181
+ try {
182
+ body = text.length > 0 ? JSON.parse(text) : undefined;
183
+ }
184
+ catch {
185
+ // non-JSON transports keep the raw text as body
186
+ }
187
+ return { status: response.status, body };
188
+ };
189
+ }
190
+ class SelfTestCaseTimeoutError extends Error {
191
+ constructor(timeoutMs) {
192
+ super(`Self-test case timed out after ${timeoutMs}ms`);
193
+ this.name = "SelfTestCaseTimeoutError";
194
+ }
195
+ }
196
+ async function withCaseTimeout(run, timeoutMs) {
197
+ let timer;
198
+ try {
199
+ return await Promise.race([
200
+ run(),
201
+ new Promise((_, reject) => {
202
+ timer = setTimeout(() => reject(new SelfTestCaseTimeoutError(timeoutMs)), timeoutMs);
203
+ }),
204
+ ]);
205
+ }
206
+ finally {
207
+ if (timer !== undefined)
208
+ clearTimeout(timer);
209
+ }
210
+ }
211
+ function objectProperty(value, key) {
212
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
213
+ return undefined;
214
+ }
215
+ for (const [entryKey, entryValue] of Object.entries(value)) {
216
+ if (entryKey === key)
217
+ return entryValue;
218
+ }
219
+ return undefined;
220
+ }
221
+ function upstreamErrorCode(body) {
222
+ const error = objectProperty(body, "error");
223
+ const code = objectProperty(error, "code");
224
+ return typeof code === "string" ? code : undefined;
225
+ }
226
+ function upstreamErrorMessage(body) {
227
+ const error = objectProperty(body, "error");
228
+ const message = objectProperty(error, "message");
229
+ return typeof message === "string" ? message : undefined;
230
+ }
231
+ /**
232
+ * How long a memoized multi-turn flow outcome suppresses re-driving the auth
233
+ * flow. Generous on purpose: a multi-turn ceremony (OTP, device approval) is a
234
+ * provider property that changes on the timescale of releases, not probe
235
+ * cycles, and every re-drive is a REAL upstream login submission. The cache is
236
+ * in-process, so a pod restart also clears the entry.
237
+ */
238
+ export const SELF_TEST_MULTI_TURN_RETRY_AFTER_MS = 24 * 60 * 60 * 1000;
239
+ /**
240
+ * Age bound for POSITIVE cached credentials. Expiry modes that never produce
241
+ * a 401/403 (a 200 login page, an assertion failure) would otherwise replay
242
+ * the same stale session until pod restart — one re-login per day is the
243
+ * upstream-safe recovery for them.
244
+ */
245
+ export const SELF_TEST_CREDENTIAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
246
+ function resolveCaseTimeoutMs(execution, suite, healthCase) {
247
+ const providerDefault = (execution.provider.healthProbe ?? execution.provider.healthMonitor)
248
+ ?.defaultProbeTimeoutMs;
249
+ return (execution.requestTimeoutMs ??
250
+ healthCase.timeoutMs ??
251
+ suite.timeoutMs ??
252
+ providerDefault ??
253
+ DEFAULT_CASE_TIMEOUT_MS);
254
+ }
255
+ function credentialSessionCacheKey(providerId, inputs) {
256
+ const canonical = JSON.stringify(Object.keys(inputs)
257
+ .sort()
258
+ .map((key) => [key, inputs[key]]));
259
+ return `${providerId}:${createHash("sha256").update(canonical).digest("hex")}`;
260
+ }
261
+ function registerSensitiveValues(execution, values) {
262
+ for (const value of values) {
263
+ if (typeof value === "string" &&
264
+ value.length > 0 &&
265
+ !execution.sensitiveValues.includes(value)) {
266
+ execution.sensitiveValues.push(value);
267
+ }
268
+ }
269
+ }
270
+ function parseAuthFlowResponse(result) {
271
+ const errorEnvelope = objectProperty(result.body, "error");
272
+ if (result.status < 200 || result.status >= 300 || errorEnvelope !== undefined) {
273
+ const message = objectProperty(errorEnvelope, "message");
274
+ return {
275
+ ok: false,
276
+ code: "auth_flow_failed",
277
+ message: typeof message === "string"
278
+ ? message
279
+ : `Auth flow request failed with status ${result.status}`,
280
+ httpStatus: result.status,
281
+ };
282
+ }
283
+ const turnValue = objectProperty(result.body, "data");
284
+ const turnKind = objectProperty(turnValue, "kind");
285
+ if (typeof turnKind !== "string") {
286
+ return {
287
+ ok: false,
288
+ code: "auth_flow_failed",
289
+ message: "Auth flow returned an unrecognized turn.",
290
+ httpStatus: result.status,
291
+ };
292
+ }
293
+ const contextPatch = objectProperty(result.body, "contextPatch");
294
+ return {
295
+ ok: true,
296
+ turn: {
297
+ kind: turnKind,
298
+ data: objectProperty(turnValue, "data"),
299
+ expectedInput: objectProperty(turnValue, "expectedInput"),
300
+ },
301
+ ...(contextPatch && typeof contextPatch === "object" && !Array.isArray(contextPatch)
302
+ ? { contextPatch: contextPatch }
303
+ : {}),
304
+ };
305
+ }
306
+ function applyAuthFlowContextPatch(base, patch) {
307
+ if (!patch)
308
+ return base;
309
+ const next = { ...base };
310
+ for (const [key, value] of Object.entries(patch)) {
311
+ if (value === null) {
312
+ delete next[key];
313
+ }
314
+ else {
315
+ next[key] = value;
316
+ }
317
+ }
318
+ return next;
319
+ }
320
+ /**
321
+ * Extracts the completed credential from a complete turn's data payload — the
322
+ * same `data.credential` record the gateway persists as connection secrets in
323
+ * production (`persistCredential` → credential-service `UpdateCredential`).
324
+ */
325
+ function completedCredentialFromTurn(turnData) {
326
+ const credential = objectProperty(turnData, "credential");
327
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
328
+ return undefined;
329
+ }
330
+ const secrets = {};
331
+ for (const [key, value] of Object.entries(credential)) {
332
+ if (typeof value === "string")
333
+ secrets[key] = value;
334
+ }
335
+ return Object.keys(secrets).length > 0 ? secrets : undefined;
336
+ }
337
+ /**
338
+ * Drives the provider's declared auth flow exactly like production does:
339
+ * `flow.start()` then a single `flow.continue(credentialInputs)`. Anything
340
+ * other than a complete turn is a visible multi-turn gap, never a fabricated
341
+ * probe failure.
342
+ */
343
+ /**
344
+ * Fields an input-prompt turn actually requests. The canonical auth-turn
345
+ * shape carries the JSON schema DIRECTLY on `expectedInput` (`ctx.auth
346
+ * .nextForm`/`defineCredentialsAuth`, the committed fixtures); some providers
347
+ * nest it as `expectedInput.schema`. Both are honored. `null` when the turn
348
+ * declares no schema (legacy/loose flows keep full-input semantics).
349
+ */
350
+ function turnRequestedFields(turn) {
351
+ const expectedInput = turn.expectedInput;
352
+ if (!expectedInput || typeof expectedInput !== "object" || Array.isArray(expectedInput)) {
353
+ return null;
354
+ }
355
+ const schemaOf = (candidate) => {
356
+ const properties = candidate && typeof candidate === "object"
357
+ ? candidate.properties
358
+ : undefined;
359
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
360
+ return null;
361
+ }
362
+ const requiredRaw = candidate && typeof candidate === "object"
363
+ ? candidate.required
364
+ : undefined;
365
+ const required = Array.isArray(requiredRaw)
366
+ ? requiredRaw.filter((field) => typeof field === "string")
367
+ : [];
368
+ return { properties: Object.keys(properties), required };
369
+ };
370
+ return (schemaOf(expectedInput) ?? schemaOf(expectedInput.schema));
371
+ }
372
+ async function materializeFlowCredential(execution, inputs, options = {}) {
373
+ const authFlow = execution.authFlow;
374
+ if (!authFlow) {
375
+ return {
376
+ kind: "flow_error",
377
+ code: "auth_flow_unavailable",
378
+ message: "Provider declares a credentials auth flow but the self-test host has no auth-flow driver.",
379
+ };
380
+ }
381
+ const flowId = `self-test-${randomUUID()}`;
382
+ // The login must ride the SAME proxy/connection affinity the probe will
383
+ // use (createAuthFlowContext keys affinity on connectionId) — otherwise
384
+ // IP/session-bound upstreams see the cookie arrive from a different
385
+ // session and reject it.
386
+ const started = parseAuthFlowResponse(await authFlow({
387
+ route: "start",
388
+ requestId: `${execution.requestId}-auth-start-${randomUUID()}`,
389
+ flowId,
390
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
391
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
392
+ }));
393
+ if (!started.ok) {
394
+ return { kind: "flow_error", code: started.code, message: started.message };
395
+ }
396
+ let turn = started.turn;
397
+ const flowContext = applyAuthFlowContextPatch({}, started.contextPatch);
398
+ if (turn.kind === "abort") {
399
+ // Terminal turn: continuing after an abort would replay credentials into
400
+ // a flow that already refused to proceed. Not memoized (flow errors are
401
+ // never cached) — an abort can be transient upstream maintenance.
402
+ return {
403
+ kind: "flow_error",
404
+ code: "auth_flow_aborted",
405
+ message: "Auth flow aborted before requesting input.",
406
+ };
407
+ }
408
+ if (turn.kind !== "complete") {
409
+ // Validate the start turn BEFORE submitting credentials: an unknown
410
+ // kind may be a provider typo or a stage that must not receive the
411
+ // probe inputs. `retry` counts as an input prompt at this stage.
412
+ if (turn.kind !== "retry" && !INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
413
+ return {
414
+ kind: "flow_error",
415
+ code: "auth_flow_unexpected_turn",
416
+ message: `Auth flow start returned an unrecognized turn kind "${turn.kind}".`,
417
+ };
418
+ }
419
+ // Auto-continue ONLY into input prompts (form/retry). Other known
420
+ // interactive stages (redirect, poll, pending, challenge, message,
421
+ // multi_choice) are valid flows that are NOT asking for the credential
422
+ // inputs — posting the password there submits it to the wrong stage.
423
+ // They are a genuine headless gap: the memoized multi-turn skip.
424
+ if (turn.kind !== "form" && turn.kind !== "retry") {
425
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
426
+ }
427
+ // Submit ONLY what the turn asks for: a first stage of a multi-step
428
+ // login may request a subset (or different fields entirely) — posting
429
+ // the full inputs would send secrets to the wrong stage. Only the
430
+ // schema's REQUIRED fields are mandatory (defineCredentialsAuth
431
+ // encodes optional fields by omitting them from `required`); a turn
432
+ // whose required fields we do not hold is a headless gap (multi-turn).
433
+ // A turn with no declared schema keeps full-input semantics.
434
+ const requestedFields = turnRequestedFields(turn);
435
+ let submitInputs = { ...inputs };
436
+ if (requestedFields !== null) {
437
+ if (requestedFields.required.some((field) => inputs[field] === undefined)) {
438
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
439
+ }
440
+ submitInputs = Object.fromEntries(requestedFields.properties
441
+ .filter((field) => inputs[field] !== undefined)
442
+ .map((field) => [field, inputs[field]]));
443
+ }
444
+ // The case deadline may have fired while start() was still running.
445
+ // Never submit real credentials into a flow whose case already
446
+ // reported self_test_timeout — a late continue is a real upstream
447
+ // login/OTP attempt nobody is waiting for.
448
+ if (options.isAbandoned?.() === true) {
449
+ return {
450
+ kind: "flow_error",
451
+ code: "self_test_timeout",
452
+ message: "Case deadline passed before credential submission; flow abandoned.",
453
+ };
454
+ }
455
+ const continued = parseAuthFlowResponse(await authFlow({
456
+ route: "continue",
457
+ requestId: `${execution.requestId}-auth-continue-${randomUUID()}`,
458
+ flowId,
459
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
460
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
461
+ input: submitInputs,
462
+ ...(Object.keys(flowContext).length > 0 ? { context: flowContext } : {}),
463
+ }));
464
+ if (!continued.ok) {
465
+ // Providers built with defineCredentialsAuth cannot return a retry
466
+ // turn — a rejected password THROWS and /auth/continue answers with
467
+ // an auth-shaped 401/403. That is a credential REJECTION (memoized,
468
+ // so the probe never hammers a locked-out login); every other
469
+ // status stays an uncached transient retry.
470
+ if (AUTH_REJECTION_HTTP_STATUSES.has(continued.httpStatus)) {
471
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
472
+ }
473
+ return { kind: "flow_error", code: continued.code, message: continued.message };
474
+ }
475
+ turn = continued.turn;
476
+ }
477
+ if (turn.kind === "abort") {
478
+ return {
479
+ kind: "flow_error",
480
+ code: "auth_flow_aborted",
481
+ message: "Auth flow aborted after credential submission.",
482
+ };
483
+ }
484
+ if (turn.kind === "retry") {
485
+ // A retry turn AFTER submission is a credential rejection, not an
486
+ // interactive gap — surfaced distinctly so monitoring can treat it as
487
+ // a real outage, and memoized by the caller (lockout safety).
488
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
489
+ }
490
+ if (turn.kind !== "complete") {
491
+ // Only KNOWN interactive kinds are a genuine "cannot complete headless"
492
+ // multi-turn gap (memoized by the caller). An unknown kind is ambiguous
493
+ // — it may encode a transient provider failure — so it reports as a
494
+ // flow error, which is never memoized, instead of freezing the signal.
495
+ if (!INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
496
+ return {
497
+ kind: "flow_error",
498
+ code: "auth_flow_unexpected_turn",
499
+ message: `Auth flow returned an unrecognized turn kind "${turn.kind}".`,
500
+ };
501
+ }
502
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
503
+ }
504
+ const credential = completedCredentialFromTurn(turn.data);
505
+ if (!credential) {
506
+ return {
507
+ kind: "flow_error",
508
+ code: "auth_flow_invalid_credential",
509
+ message: "Auth flow completed without a string-valued credential payload.",
510
+ };
511
+ }
512
+ // Redaction contract: flow-issued secrets are registered BEFORE any probe
513
+ // output can be built from them.
514
+ registerSensitiveValues(execution, Object.values(credential));
515
+ return { credential };
516
+ }
517
+ async function resolveSelfTestConnection(execution, operationId, suite, options = {}) {
518
+ if (!suite.requiresConnection)
519
+ return { kind: "connection" };
520
+ const inputs = execution.credentials ?? {};
521
+ const declaredFields = Object.keys((execution.provider.healthProbe ?? execution.provider.healthMonitor)?.credentialInputs ?? {});
522
+ for (const field of declaredFields) {
523
+ if (!inputs[field]) {
524
+ return { kind: "skip", skipReason: `credential_missing:${field}` };
525
+ }
526
+ }
527
+ if (declaredFields.length === 0 && Object.keys(inputs).length === 0) {
528
+ return { kind: "skip", skipReason: "credential_missing:credentials" };
529
+ }
530
+ // The connection id seeds proxy/connection affinity in the provider
531
+ // context, so it must be STABLE per (provider, credentialInputs): a cached
532
+ // session replayed under a per-request id would ride a different proxy/IP
533
+ // each cycle and upstreams would treat the cookie as stale or suspicious.
534
+ // The id carries only a hash of the inputs, never the inputs themselves.
535
+ //
536
+ // Providers declaring `proxy.session.affinity: "operation"` pin the PROBE's
537
+ // proxy to `${providerId}/${operationId}` regardless of connection id — so
538
+ // the login must ride that exact key, and the session cache splits per
539
+ // operation (one shared cookie would otherwise hop between per-operation
540
+ // proxies).
541
+ const operationAffinity = typeof execution.provider.proxy === "object" &&
542
+ execution.provider.proxy?.session?.affinity === "operation";
543
+ const credentialKey = credentialSessionCacheKey(execution.provider.id, inputs);
544
+ const affinityKey = operationAffinity ? `${credentialKey}:${operationId}` : credentialKey;
545
+ // ONE id for the auth flow AND the probe connection: providers may bind
546
+ // the issued credential to FlowContext.connectionId and later compare it
547
+ // against ctx.request.connectionId. Operation-affinity providers use the
548
+ // probe's exact proxy key (providerId/operationId); everyone else uses the
549
+ // stable per-credential hash.
550
+ const connectionId = operationAffinity
551
+ ? `${execution.provider.id}/${operationId}`
552
+ : `self-test-${createHash("sha256").update(affinityKey).digest("hex").slice(0, 22)}`;
553
+ const buildConnection = (secrets) => ({
554
+ id: connectionId,
555
+ mode: "credentials",
556
+ secrets: { ...secrets },
557
+ metadata: { purpose: "provider-self-test", operationId },
558
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
559
+ });
560
+ const auth = execution.provider.auth;
561
+ if (auth?.mode !== "credentials" || !auth.flow) {
562
+ // Providers without a declared credentials flow keep raw-input semantics
563
+ // — and the pre-existing per-request connection id: there is no session
564
+ // to keep on one affinity, and a stable id would silently pin every
565
+ // cycle of a connection-affinity proxy to the same upstream session.
566
+ return {
567
+ kind: "connection",
568
+ connection: {
569
+ ...buildConnection(inputs),
570
+ id: `self-test-${execution.requestId}`,
571
+ },
572
+ credentialSource: "inputs",
573
+ };
574
+ }
575
+ const cacheKey = affinityKey;
576
+ const cached = execution.sessionCache.get(cacheKey);
577
+ // DR-7 upstream-account safety: a memoized multi-turn outcome
578
+ // short-circuits to the auth_flow_multi_turn skip WITHOUT re-driving
579
+ // flow.start()/flow.continue() — every re-drive is a real upstream login
580
+ // submission (OTP sends, lockout risk), and the probe scheduler would
581
+ // otherwise repeat it every cycle forever. Changed credentialInputs hash
582
+ // to a different key and re-attempt immediately; otherwise the entry
583
+ // expires after a generous TTL (or process restart) so a provider whose
584
+ // flow becomes single-turn again is eventually re-probed.
585
+ if (cached?.kind === "multi_turn" || cached?.kind === "rejected") {
586
+ if (Date.now() - cached.cachedAtMs < SELF_TEST_MULTI_TURN_RETRY_AFTER_MS) {
587
+ return {
588
+ kind: "skip",
589
+ skipReason: cached.kind === "rejected"
590
+ ? SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON
591
+ : SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON,
592
+ };
593
+ }
594
+ execution.sessionCache.delete(cacheKey);
595
+ }
596
+ if (options.forceLogin !== true && cached?.kind === "credential") {
597
+ if (Date.now() - cached.cachedAtMs >= SELF_TEST_CREDENTIAL_MAX_AGE_MS) {
598
+ // Age-bounded: expiry modes that never 401 (login-page 200s,
599
+ // assertion failures) must not replay one stale session forever.
600
+ execution.sessionCache.delete(cacheKey);
601
+ }
602
+ else {
603
+ registerSensitiveValues(execution, Object.values(cached.credential));
604
+ return {
605
+ kind: "connection",
606
+ connection: buildConnection(cached.credential),
607
+ credentialSource: "cache",
608
+ cacheKey,
609
+ };
610
+ }
611
+ }
612
+ const materialized = await materializeFlowCredential(execution, inputs, {
613
+ ...(options.isAbandoned !== undefined ? { isAbandoned: options.isAbandoned } : {}),
614
+ connectionId,
615
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
616
+ });
617
+ if (!("credential" in materialized)) {
618
+ // Only the multi-turn SKIP is negative-cached. Flow ERRORS
619
+ // (auth_flow_unavailable / auth_flow_failed / invalid credential
620
+ // payloads, or a thrown start/continue) are never memoized: they are
621
+ // typically transient upstream or host failures, so each cycle may
622
+ // retry — permanently caching an error would silently freeze the
623
+ // signal on a blip, while retrying a FAILED request is not a repeated
624
+ // successful login submission.
625
+ if (materialized.kind === "skip" && options.isAbandoned?.() !== true) {
626
+ if (materialized.skipReason === SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON) {
627
+ execution.sessionCache.set(cacheKey, { kind: "multi_turn", cachedAtMs: Date.now() });
628
+ }
629
+ else if (materialized.skipReason === SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON) {
630
+ execution.sessionCache.set(cacheKey, { kind: "rejected", cachedAtMs: Date.now() });
631
+ }
632
+ }
633
+ return materialized;
634
+ }
635
+ // A flow that outlived the case deadline still completes here (the timeout
636
+ // only races the promise, it cannot cancel it). The case already reported
637
+ // self_test_timeout — caching this credential would let the next probe
638
+ // reuse a login whose latency just failed the case, hiding the failure.
639
+ if (options.isAbandoned?.() === true) {
640
+ return {
641
+ kind: "flow_error",
642
+ code: "self_test_timeout",
643
+ message: "Auth flow completed after the case deadline; credential discarded.",
644
+ };
645
+ }
646
+ execution.sessionCache.set(cacheKey, {
647
+ kind: "credential",
648
+ credential: materialized.credential,
649
+ cachedAtMs: Date.now(),
650
+ });
651
+ return {
652
+ kind: "connection",
653
+ connection: buildConnection(materialized.credential),
654
+ credentialSource: "flow",
655
+ cacheKey,
656
+ };
657
+ }
658
+ /** A failed probe whose HTTP status is auth-shaped invalidates a cached session once. */
659
+ function isAuthFailureCaseResult(result) {
660
+ return result.status === "failed" && (result.httpStatus === 401 || result.httpStatus === 403);
661
+ }
662
+ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
663
+ const { provider, invoke } = execution;
664
+ // execution.sensitiveValues may grow while the case runs (flow-issued
665
+ // secrets); redact always reads the live array.
666
+ const redact = (text) => redactSelfTestText(text, execution.sensitiveValues);
667
+ const defaultLabel = redact(healthCase.description ?? healthCase.name);
668
+ const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
669
+ // One deadline for the WHOLE case: connection materialization (auth flow),
670
+ // the probe, and the one-shot auth retry all draw from the same budget —
671
+ // a 30s case must never take ~4×30s across its stages.
672
+ const caseDeadlineAtMs = performance.now() + timeoutMs;
673
+ const remainingCaseTimeoutMs = () => Math.max(1, Math.ceil(caseDeadlineAtMs - performance.now()));
674
+ const beginCase = () => {
675
+ const startedAt = new Date().toISOString();
676
+ const startedAtMs = performance.now();
677
+ return {
678
+ startedAtMs,
679
+ finish: (partial) => ({
680
+ operationId,
681
+ caseName: healthCase.name,
682
+ startedAt,
683
+ finishedAt: new Date().toISOString(),
684
+ responseTimeMs: partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
685
+ ...partial,
686
+ }),
687
+ };
688
+ };
689
+ const caseScope = beginCase();
690
+ if (healthCase.enabled && healthCase.enabled() === false) {
691
+ return caseScope.finish({
692
+ status: "skipped",
693
+ label: defaultLabel,
694
+ skipReason: "disabled",
695
+ });
696
+ }
697
+ const resolveConnection = async (forceLogin) => {
698
+ // The timeout only races the flow promise — it cannot cancel it. Once
699
+ // the deadline fires, the still-running resolution is marked abandoned
700
+ // so its late completion cannot write the session cache.
701
+ let abandoned = false;
702
+ try {
703
+ return await withCaseTimeout(() => resolveSelfTestConnection(execution, operationId, suite, {
704
+ forceLogin,
705
+ isAbandoned: () => abandoned,
706
+ }), remainingCaseTimeoutMs());
707
+ }
708
+ catch (error) {
709
+ abandoned = true;
710
+ return {
711
+ kind: "flow_error",
712
+ code: error instanceof SelfTestCaseTimeoutError ? "self_test_timeout" : "auth_flow_failed",
713
+ message: error instanceof Error ? error.message : String(error),
714
+ };
715
+ }
716
+ };
717
+ const nonConnectionResult = (resolution) => {
718
+ if (resolution.kind === "skip") {
719
+ return caseScope.finish({
720
+ status: "skipped",
721
+ label: defaultLabel,
722
+ skipReason: resolution.skipReason,
723
+ });
724
+ }
725
+ return caseScope.finish({
726
+ status: "error",
727
+ label: defaultLabel,
728
+ error: { code: resolution.code, message: redact(resolution.message) },
729
+ });
730
+ };
731
+ const runProbeAttempt = async (connection) => {
732
+ // gateway.execute keeps its contract — EVERY helper status is returned
733
+ // to the prepareInput hook (it may branch on 401 itself). The last
734
+ // auth-shaped helper status is only RECORDED: if the hook then throws,
735
+ // the case fails WITH that status so stale-session recovery triggers.
736
+ let prepareAuthStatus = null;
737
+ // Share the OUTER case scope: startedAt/responseTimeMs must cover the
738
+ // WHOLE case — auth-flow materialization included — not just the final
739
+ // operation attempt, or a slow login reads as a fast healthy case.
740
+ const { startedAtMs, finish } = caseScope;
741
+ try {
742
+ return await withCaseTimeout(async () => {
743
+ const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
744
+ const preparedInput = healthCase.prepareInput
745
+ ? await healthCase.prepareInput({
746
+ providerId: provider.id,
747
+ operationId,
748
+ input: resolvedInput,
749
+ ...(connection ? { connectionId: connection.id } : {}),
750
+ gateway: {
751
+ execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
752
+ if (foreignProviderId !== provider.id) {
753
+ throw new Error(`Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`);
754
+ }
755
+ const startedGatewayMs = performance.now();
756
+ const executed = await invoke({
757
+ operationId: gatewayOperationId,
758
+ input: gatewayInput,
759
+ connection,
760
+ requestId: `${execution.requestId}-prepare-${randomUUID()}`,
761
+ });
762
+ if (executed.status === 401 || executed.status === 403) {
763
+ prepareAuthStatus = executed.status;
764
+ }
765
+ return {
766
+ status: executed.status,
767
+ duration: performance.now() - startedGatewayMs,
768
+ data: executed.data,
769
+ meta: executed.meta,
770
+ };
771
+ },
772
+ },
773
+ })
774
+ : resolvedInput;
775
+ const executed = await invoke({
776
+ operationId,
777
+ input: preparedInput,
778
+ connection,
779
+ requestId: `${execution.requestId}-${randomUUID()}`,
780
+ });
781
+ const durationMs = performance.now() - startedAtMs;
782
+ if (executed.status < 200 || executed.status >= 300) {
783
+ return finish({
784
+ status: "failed",
785
+ label: defaultLabel,
786
+ httpStatus: executed.status,
787
+ error: {
788
+ code: upstreamErrorCode(executed.data) ?? "operation_failed",
789
+ message: redact(upstreamErrorMessage(executed.data) ??
790
+ `Operation invocation failed with status ${executed.status}`),
791
+ },
792
+ });
793
+ }
794
+ const assertionContext = {
795
+ status: executed.status,
796
+ data: executed.data,
797
+ durationMs,
798
+ ...(executed.meta ? { meta: executed.meta } : {}),
799
+ };
800
+ let assertionResult;
801
+ try {
802
+ assertionResult = await healthCase.assertions(assertionContext);
803
+ }
804
+ catch (assertionError) {
805
+ return finish({
806
+ status: "failed",
807
+ label: defaultLabel,
808
+ httpStatus: executed.status,
809
+ assertion: {
810
+ passed: false,
811
+ message: redact(assertionError instanceof Error ? assertionError.message : String(assertionError)),
812
+ },
813
+ });
814
+ }
815
+ const statusValue = objectProperty(assertionResult, "status");
816
+ const overrideStatus = statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
817
+ const labelValue = objectProperty(assertionResult, "label");
818
+ const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
819
+ return finish({
820
+ status: overrideStatus ?? "ok",
821
+ label: overrideLabel ?? defaultLabel,
822
+ httpStatus: executed.status,
823
+ assertion: { passed: true },
824
+ });
825
+ }, remainingCaseTimeoutMs());
826
+ }
827
+ catch (error) {
828
+ if (error instanceof SelfTestCaseTimeoutError) {
829
+ return finish({
830
+ status: "error",
831
+ label: defaultLabel,
832
+ error: { code: "self_test_timeout", message: redact(error.message) },
833
+ });
834
+ }
835
+ if (prepareAuthStatus !== null) {
836
+ return finish({
837
+ status: "failed",
838
+ label: defaultLabel,
839
+ httpStatus: prepareAuthStatus,
840
+ assertion: {
841
+ passed: false,
842
+ message: redact(error instanceof Error ? error.message : String(error)),
843
+ },
844
+ });
845
+ }
846
+ return finish({
847
+ status: "error",
848
+ label: defaultLabel,
849
+ error: {
850
+ code: "self_test_execution_error",
851
+ message: redact(error instanceof Error ? error.message : String(error)),
852
+ },
853
+ });
854
+ }
855
+ };
856
+ const resolution = await resolveConnection(false);
857
+ if (resolution.kind !== "connection") {
858
+ return nonConnectionResult(resolution);
859
+ }
860
+ let result = await runProbeAttempt(resolution.connection);
861
+ // One-shot session recovery: a cached credential that fails the probe with
862
+ // an auth-shaped status is invalidated, the flow re-runs ONCE, and the
863
+ // probe retries once. Fresh (just-materialized) credentials never retry.
864
+ if (resolution.credentialSource === "cache" &&
865
+ resolution.cacheKey !== undefined &&
866
+ isAuthFailureCaseResult(result)) {
867
+ execution.sessionCache.delete(resolution.cacheKey);
868
+ const retryResolution = await resolveConnection(true);
869
+ if (retryResolution.kind !== "connection") {
870
+ return nonConnectionResult(retryResolution);
871
+ }
872
+ result = await runProbeAttempt(retryResolution.connection);
873
+ // The retry's fresh credential is subject to the same eviction rule
874
+ // as a first-attempt fresh credential (below).
875
+ if (retryResolution.cacheKey !== undefined && isAuthFailureCaseResult(result)) {
876
+ execution.sessionCache.delete(retryResolution.cacheKey);
877
+ }
878
+ return result;
879
+ }
880
+ // A FRESH credential the probe just rejected is known-bad: evict it so the
881
+ // next cycle logs in anew instead of replaying a guaranteed-stale session
882
+ // once before recovering. (No retry here — fresh credentials never retry.)
883
+ if (resolution.credentialSource === "flow" &&
884
+ resolution.cacheKey !== undefined &&
885
+ isAuthFailureCaseResult(result)) {
886
+ execution.sessionCache.delete(resolution.cacheKey);
887
+ }
888
+ return result;
889
+ }
890
+ function selectCases(provider, request) {
891
+ const singleCase = request.operationId !== undefined && request.caseName !== undefined;
892
+ if (singleCase) {
893
+ const operationId = request.operationId;
894
+ const operation = provider.operations[operationId];
895
+ const suite = operation ? healthCheckSuite(operation) : undefined;
896
+ const healthCase = suite?.cases.find((candidate) => candidate.name === request.caseName);
897
+ if (!operation || !suite || !healthCase) {
898
+ return {
899
+ errorStatus: 422,
900
+ errorCode: "case_not_found",
901
+ errorMessage: `Case "${request.caseName}" not found for operation "${operationId}".`,
902
+ };
903
+ }
904
+ if (!isSelfTestReadOnlyOperation(operation)) {
905
+ return {
906
+ errorStatus: 403,
907
+ errorCode: "operation_not_read_only",
908
+ errorMessage: `Operation "${operationId}" is not classified read-only; self-test refuses to execute it.`,
909
+ };
910
+ }
911
+ return { cases: [{ operationId, operation, suite, healthCase }] };
912
+ }
913
+ const operationFilter = request.operations ? new Set(request.operations) : undefined;
914
+ const caseNameFilter = request.caseNames ? new Set(request.caseNames) : undefined;
915
+ if (operationFilter) {
916
+ for (const operationId of operationFilter) {
917
+ const operation = provider.operations[operationId];
918
+ if (!operation || !healthCheckSuite(operation)) {
919
+ return {
920
+ errorStatus: 422,
921
+ errorCode: "case_not_found",
922
+ errorMessage: `Operation "${operationId}" has no declared healthCheck cases.`,
923
+ };
924
+ }
925
+ }
926
+ }
927
+ const cases = [];
928
+ for (const operationId of Object.keys(provider.operations).sort()) {
929
+ if (operationFilter && !operationFilter.has(operationId))
930
+ continue;
931
+ const operation = provider.operations[operationId];
932
+ const suite = operation ? healthCheckSuite(operation) : undefined;
933
+ if (!operation || !suite)
934
+ continue;
935
+ for (const healthCase of suite.cases) {
936
+ if (caseNameFilter && !caseNameFilter.has(healthCase.name))
937
+ continue;
938
+ cases.push({ operationId, operation, suite, healthCase });
939
+ }
940
+ }
941
+ if (cases.length === 0) {
942
+ return {
943
+ errorStatus: 422,
944
+ errorCode: "case_not_found",
945
+ errorMessage: "No declared healthCheck cases matched the selection.",
946
+ };
947
+ }
948
+ return { cases };
949
+ }
950
+ function resolveRequestBudgetMs(options) {
951
+ if (options.requestBudgetMs !== undefined)
952
+ return options.requestBudgetMs;
953
+ const env = options.env ?? process.env;
954
+ const raw = env[PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV];
955
+ const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
956
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SELF_TEST_REQUEST_BUDGET_MS;
957
+ }
958
+ export function resolveSelfTestPort(env = process.env) {
959
+ const raw = env[PROVIDER_RUNTIME_SELF_TEST_PORT_ENV];
960
+ const parsed = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
961
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535
962
+ ? parsed
963
+ : DEFAULT_SELF_TEST_PORT;
964
+ }
965
+ /**
966
+ * Builds the internal self-test Hono app. This app is served on a SEPARATE
967
+ * listener (default :3001) that the tenant-facing gateway never dials; when no
968
+ * master secret is resolvable the self-test route responds 404 (safe default
969
+ * off) while `GET /healthz` stays available for liveness.
970
+ */
971
+ export function createSelfTestApp(provider, options) {
972
+ const app = new Hono();
973
+ const planDigest = computeSelfTestPlanDigest(provider);
974
+ const requestBudgetMs = resolveRequestBudgetMs(options);
975
+ // In-process flow-credential session cache (providerId + credentialInputs
976
+ // hash → materialized credential). Lives as long as the app so consecutive
977
+ // probe cycles never log in to the upstream more than once per session.
978
+ const sessionCache = new Map();
979
+ let busy = false;
980
+ app.notFound((c) => c.json({ error: { code: "not_found", message: "Not found" } }, 404));
981
+ app.get(SELF_TEST_HEALTHZ_PATH, (c) => c.json({ ok: true }));
982
+ const handleSelfTest = async (c) => {
983
+ if (!options.secrets) {
984
+ return c.json({ error: { code: "not_found", message: "Not found" } }, 404);
985
+ }
986
+ const authorized = verifySelfTestAuthorization(c.req.raw.headers.get("authorization") ?? undefined, provider.id, options.secrets);
987
+ if (!authorized) {
988
+ return c.json({ error: { code: "unauthorized", message: "Unauthorized" } }, 401);
989
+ }
990
+ const rawBody = await c.req.raw
991
+ .clone()
992
+ .json()
993
+ .catch(() => undefined);
994
+ const schemaVersion = objectProperty(rawBody, "schemaVersion");
995
+ if (schemaVersion !== SELF_TEST_SCHEMA_VERSION) {
996
+ return c.json({
997
+ error: {
998
+ code: "unsupported_schema_version",
999
+ message: `Unsupported self-test schemaVersion; supported: [${SELF_TEST_SCHEMA_VERSION}].`,
1000
+ supported: [SELF_TEST_SCHEMA_VERSION],
1001
+ },
1002
+ }, 400);
1003
+ }
1004
+ const parsed = SelfTestRequestSchema.safeParse(rawBody);
1005
+ if (!parsed.success) {
1006
+ return c.json({
1007
+ error: {
1008
+ code: "invalid_request",
1009
+ message: "Invalid self-test request body",
1010
+ },
1011
+ }, 400);
1012
+ }
1013
+ const request = parsed.data;
1014
+ if ((request.operationId === undefined) !== (request.caseName === undefined)) {
1015
+ return c.json({
1016
+ error: {
1017
+ code: "invalid_request",
1018
+ message: "operationId and caseName must be provided together for single-case requests.",
1019
+ },
1020
+ }, 400);
1021
+ }
1022
+ const selection = selectCases(provider, request);
1023
+ if ("errorStatus" in selection) {
1024
+ return c.json({
1025
+ error: {
1026
+ code: selection.errorCode,
1027
+ message: selection.errorMessage,
1028
+ },
1029
+ }, selection.errorStatus);
1030
+ }
1031
+ if (busy) {
1032
+ return c.json({
1033
+ error: {
1034
+ code: "self_test_busy",
1035
+ message: "A self-test request is already executing.",
1036
+ },
1037
+ retryAfterMs: SELF_TEST_BUSY_RETRY_AFTER_MS,
1038
+ }, 409);
1039
+ }
1040
+ busy = true;
1041
+ try {
1042
+ const execution = {
1043
+ provider,
1044
+ invoke: options.invoke,
1045
+ ...(options.authFlow ? { authFlow: options.authFlow } : {}),
1046
+ requestId: request.requestId,
1047
+ credentials: request.credentials?.inputs,
1048
+ requestTimeoutMs: request.timeoutMs,
1049
+ sensitiveValues: collectSelfTestSensitiveValues(provider, {
1050
+ env: options.env,
1051
+ credentialInputs: request.credentials?.inputs,
1052
+ }),
1053
+ sessionCache,
1054
+ };
1055
+ const deadline = performance.now() + requestBudgetMs;
1056
+ const results = [];
1057
+ // Sequential execution (parallelism 1): self-tests run on serving pods
1058
+ // and must never compete with themselves for upstream quota.
1059
+ for (const selected of selection.cases) {
1060
+ if (performance.now() >= deadline) {
1061
+ const now = new Date().toISOString();
1062
+ results.push({
1063
+ operationId: selected.operationId,
1064
+ caseName: selected.healthCase.name,
1065
+ status: "skipped",
1066
+ label: selected.healthCase.name,
1067
+ responseTimeMs: 0,
1068
+ skipReason: "budget_exhausted",
1069
+ startedAt: now,
1070
+ finishedAt: now,
1071
+ });
1072
+ continue;
1073
+ }
1074
+ if (!isSelfTestReadOnlyOperation(selected.operation)) {
1075
+ const now = new Date().toISOString();
1076
+ results.push({
1077
+ operationId: selected.operationId,
1078
+ caseName: selected.healthCase.name,
1079
+ status: "error",
1080
+ label: selected.healthCase.name,
1081
+ responseTimeMs: 0,
1082
+ error: {
1083
+ code: "operation_not_read_only",
1084
+ message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
1085
+ },
1086
+ startedAt: now,
1087
+ finishedAt: now,
1088
+ });
1089
+ continue;
1090
+ }
1091
+ results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase));
1092
+ }
1093
+ const singleCase = request.operationId !== undefined && request.caseName !== undefined;
1094
+ const response = {
1095
+ schemaVersion: SELF_TEST_SCHEMA_VERSION,
1096
+ providerId: provider.id,
1097
+ sdkVersion: SDK_VERSION,
1098
+ planDigest,
1099
+ ...(singleCase && results[0] ? { result: results[0] } : {}),
1100
+ results,
1101
+ };
1102
+ return c.json(response, 200);
1103
+ }
1104
+ finally {
1105
+ busy = false;
1106
+ }
1107
+ };
1108
+ app.post(SELF_TEST_PATH, handleSelfTest);
1109
+ // Transitional alias so schedulers can address the endpoint by its short
1110
+ // path; the canonical path is /internal/health/self-test.
1111
+ app.post("/self-test", handleSelfTest);
1112
+ return app;
1113
+ }