@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.40

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 (303) hide show
  1. package/AUTHORING.md +501 -13
  2. package/CHANGELOG.md +165 -1
  3. package/README.md +72 -14
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +88 -4
  6. package/bin/apifuse-dev.ts +38 -5
  7. package/bin/apifuse-pack-check.ts +22 -2
  8. package/bin/apifuse-pack-smoke.ts +57 -2
  9. package/bin/apifuse-pack-types.ts +356 -38
  10. package/bin/apifuse-perf.ts +14 -13
  11. package/bin/apifuse-record.ts +691 -68
  12. package/bin/apifuse-submit-check.ts +518 -37
  13. package/bin/apifuse-sync-assets.ts +117 -0
  14. package/bin/submit-check-delimited-text.ts +50 -0
  15. package/dist/auth-turn/index.d.ts +3 -3
  16. package/dist/auth-turn/index.js +1 -1
  17. package/dist/auth.d.ts +14 -0
  18. package/dist/auth.js +67 -0
  19. package/dist/ceremonies/index.d.ts +16 -0
  20. package/dist/ceremonies/index.js +141 -36
  21. package/dist/cli/commands.d.ts +1 -1
  22. package/dist/cli/commands.js +8 -0
  23. package/dist/cli/create.d.ts +3 -0
  24. package/dist/cli/create.js +34 -35
  25. package/dist/cli/prompt-assets.d.ts +80 -0
  26. package/dist/cli/prompt-assets.js +743 -0
  27. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  28. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  29. package/dist/cli/templates/provider/README.md.tpl +4 -4
  30. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  31. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
  32. package/dist/config/loader.d.ts +177 -16
  33. package/dist/config/loader.js +424 -127
  34. package/dist/contract-serialization.js +4 -8
  35. package/dist/contract-types.d.ts +1 -0
  36. package/dist/contract.js +2 -0
  37. package/dist/declaration-validation.d.ts +32 -0
  38. package/dist/declaration-validation.js +207 -0
  39. package/dist/define.d.ts +51 -25
  40. package/dist/define.js +752 -38
  41. package/dist/error-resolution.d.ts +4 -0
  42. package/dist/error-resolution.js +122 -0
  43. package/dist/errors.d.ts +18 -0
  44. package/dist/errors.js +40 -0
  45. package/dist/fixture-sanitization.d.ts +28 -0
  46. package/dist/fixture-sanitization.js +217 -0
  47. package/dist/health-scenario.d.ts +1842 -0
  48. package/dist/health-scenario.js +624 -0
  49. package/dist/index.d.ts +19 -9
  50. package/dist/index.js +10 -6
  51. package/dist/lint.d.ts +6 -1
  52. package/dist/lint.js +362 -3
  53. package/dist/native-address.d.ts +43 -0
  54. package/dist/native-address.js +281 -0
  55. package/dist/native-egress-policy.d.ts +31 -0
  56. package/dist/native-egress-policy.js +288 -0
  57. package/dist/observability.d.ts +5 -2
  58. package/dist/observability.js +48 -1
  59. package/dist/provider.d.ts +8 -2
  60. package/dist/provider.js +3 -1
  61. package/dist/runtime/auth-flow.d.ts +5 -1
  62. package/dist/runtime/auth-flow.js +6 -0
  63. package/dist/runtime/browser.d.ts +1 -0
  64. package/dist/runtime/browser.js +492 -49
  65. package/dist/runtime/cache.d.ts +1 -0
  66. package/dist/runtime/cache.js +169 -15
  67. package/dist/runtime/choice-wordlist.d.ts +9 -0
  68. package/dist/runtime/choice-wordlist.js +138 -0
  69. package/dist/runtime/choice.d.ts +13 -1
  70. package/dist/runtime/choice.js +490 -102
  71. package/dist/runtime/executor.d.ts +2 -2
  72. package/dist/runtime/executor.js +26 -2
  73. package/dist/runtime/http.d.ts +1 -0
  74. package/dist/runtime/http.js +515 -53
  75. package/dist/runtime/instrumentation.d.ts +2 -2
  76. package/dist/runtime/instrumentation.js +366 -8
  77. package/dist/runtime/native-network-errors.d.ts +33 -0
  78. package/dist/runtime/native-network-errors.js +69 -0
  79. package/dist/runtime/native-network.d.ts +96 -0
  80. package/dist/runtime/native-network.js +1232 -0
  81. package/dist/runtime/ocr.d.ts +29 -0
  82. package/dist/runtime/ocr.js +440 -0
  83. package/dist/runtime/proxy-errors.js +6 -2
  84. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  85. package/dist/runtime/proxy-nodemaven.js +146 -0
  86. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  87. package/dist/runtime/proxy-telemetry.js +154 -47
  88. package/dist/runtime/redirects.d.ts +29 -0
  89. package/dist/runtime/redirects.js +36 -0
  90. package/dist/runtime/redis.d.ts +1 -1
  91. package/dist/runtime/redis.js +4 -2
  92. package/dist/runtime/request-options.d.ts +68 -1
  93. package/dist/runtime/request-options.js +548 -0
  94. package/dist/runtime/resolver-config.d.ts +6 -0
  95. package/dist/runtime/resolver-config.js +6 -0
  96. package/dist/runtime/resolver-public.d.ts +1 -0
  97. package/dist/runtime/resolver-public.js +1 -0
  98. package/dist/runtime/resolver-shared.d.ts +3 -0
  99. package/dist/runtime/resolver-shared.js +12 -0
  100. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  101. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  102. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  103. package/dist/runtime/resolver-vendors/browser.js +377 -0
  104. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  105. package/dist/runtime/resolver-vendors/capsolver.js +526 -0
  106. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  107. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  108. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  109. package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
  110. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  111. package/dist/runtime/resolver-vendors/types.js +96 -0
  112. package/dist/runtime/resolver.d.ts +60 -0
  113. package/dist/runtime/resolver.js +737 -0
  114. package/dist/runtime/secrets.d.ts +27 -0
  115. package/dist/runtime/secrets.js +51 -0
  116. package/dist/runtime/state.d.ts +3 -0
  117. package/dist/runtime/state.js +277 -71
  118. package/dist/runtime/stealth-cookies.d.ts +20 -0
  119. package/dist/runtime/stealth-cookies.js +111 -0
  120. package/dist/runtime/stealth.d.ts +28 -3
  121. package/dist/runtime/stealth.js +519 -255
  122. package/dist/runtime/stt.js +1 -12
  123. package/dist/runtime/timeout.d.ts +5 -0
  124. package/dist/runtime/timeout.js +12 -0
  125. package/dist/runtime/trace-config.d.ts +12 -0
  126. package/dist/runtime/trace-config.js +61 -0
  127. package/dist/serve.d.ts +1 -1
  128. package/dist/serve.js +1 -1
  129. package/dist/server/index.d.ts +5 -3
  130. package/dist/server/index.js +3 -3
  131. package/dist/server/self-test-input-tokens.d.ts +2 -1
  132. package/dist/server/self-test-input-tokens.js +18 -14
  133. package/dist/server/self-test.d.ts +114 -0
  134. package/dist/server/self-test.js +784 -148
  135. package/dist/server/serve-implementation.d.ts +213 -0
  136. package/dist/server/serve-implementation.js +2173 -0
  137. package/dist/server/serve.d.ts +1 -70
  138. package/dist/server/serve.js +1 -1130
  139. package/dist/server/trace-output.d.ts +4 -0
  140. package/dist/server/trace-output.js +20 -0
  141. package/dist/server/types.d.ts +30 -5
  142. package/dist/server/types.js +13 -1
  143. package/dist/stateful/errors.d.ts +19 -0
  144. package/dist/stateful/errors.js +24 -0
  145. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  146. package/dist/stateful/http-provider-event-emitter.js +237 -0
  147. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  148. package/dist/stateful/http-session-owner-registry.js +210 -0
  149. package/dist/stateful/index.d.ts +18 -0
  150. package/dist/stateful/index.js +18 -0
  151. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  152. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  153. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  154. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  155. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  156. package/dist/stateful/provider-event-pipeline.js +1 -0
  157. package/dist/stateful/provider-events.d.ts +101 -0
  158. package/dist/stateful/provider-events.js +289 -0
  159. package/dist/stateful/session-key.d.ts +15 -0
  160. package/dist/stateful/session-key.js +86 -0
  161. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  162. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  163. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  164. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  165. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  166. package/dist/stateful/stateful-provider-adapter.js +287 -0
  167. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  168. package/dist/stateful/stateful-provider-observability.js +161 -0
  169. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  170. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  171. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  172. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  173. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  174. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  175. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  176. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  177. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  178. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  179. package/dist/stateful-signing.d.ts +18 -0
  180. package/dist/stateful-signing.js +27 -0
  181. package/dist/stealth/profiles.js +16 -7
  182. package/dist/stream-evidence.d.ts +74 -0
  183. package/dist/stream-evidence.js +785 -0
  184. package/dist/stream.js +7 -1
  185. package/dist/testing/index.d.ts +2 -1
  186. package/dist/testing/index.js +2 -1
  187. package/dist/testing/run.d.ts +32 -2
  188. package/dist/testing/run.js +489 -21
  189. package/dist/trace-sanitization.d.ts +5 -0
  190. package/dist/trace-sanitization.js +45 -0
  191. package/dist/types.d.ts +545 -23
  192. package/dist/types.js +1 -0
  193. package/package.json +44 -5
  194. package/src/auth-turn/index.ts +1 -1
  195. package/src/auth.ts +118 -0
  196. package/src/ceremonies/index.ts +189 -46
  197. package/src/cli/commands.ts +10 -0
  198. package/src/cli/create.ts +42 -35
  199. package/src/cli/prompt-assets.ts +865 -0
  200. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  201. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  202. package/src/cli/templates/provider/README.md.tpl +4 -4
  203. package/src/cli/templates/provider/index.ts.tpl +6 -3
  204. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
  205. package/src/config/loader.ts +665 -163
  206. package/src/contract-serialization.ts +5 -7
  207. package/src/contract-types.ts +1 -0
  208. package/src/contract.ts +2 -0
  209. package/src/declaration-validation.ts +266 -0
  210. package/src/define.ts +970 -87
  211. package/src/error-resolution.ts +127 -0
  212. package/src/errors.ts +52 -0
  213. package/src/fixture-sanitization.ts +248 -0
  214. package/src/health-scenario.ts +875 -0
  215. package/src/index.ts +204 -8
  216. package/src/lint.ts +408 -4
  217. package/src/native-address.ts +340 -0
  218. package/src/native-egress-policy.ts +358 -0
  219. package/src/observability.ts +51 -1
  220. package/src/provider.ts +133 -0
  221. package/src/runtime/auth-flow.ts +12 -0
  222. package/src/runtime/browser.ts +661 -63
  223. package/src/runtime/cache.ts +189 -14
  224. package/src/runtime/choice-wordlist.ts +145 -0
  225. package/src/runtime/choice.ts +631 -120
  226. package/src/runtime/executor.ts +40 -7
  227. package/src/runtime/http.ts +641 -61
  228. package/src/runtime/instrumentation.ts +520 -15
  229. package/src/runtime/native-network-errors.ts +99 -0
  230. package/src/runtime/native-network.ts +1605 -0
  231. package/src/runtime/ocr.ts +523 -0
  232. package/src/runtime/proxy-errors.ts +12 -4
  233. package/src/runtime/proxy-nodemaven.ts +221 -0
  234. package/src/runtime/proxy-telemetry.ts +244 -75
  235. package/src/runtime/redirects.ts +66 -0
  236. package/src/runtime/redis.ts +7 -2
  237. package/src/runtime/request-options.ts +680 -1
  238. package/src/runtime/resolver-config.ts +6 -0
  239. package/src/runtime/resolver-public.ts +20 -0
  240. package/src/runtime/resolver-shared.ts +17 -0
  241. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  242. package/src/runtime/resolver-vendors/browser.ts +533 -0
  243. package/src/runtime/resolver-vendors/capsolver.ts +700 -0
  244. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  245. package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
  246. package/src/runtime/resolver-vendors/types.ts +212 -0
  247. package/src/runtime/resolver.ts +1103 -0
  248. package/src/runtime/secrets.ts +64 -0
  249. package/src/runtime/state.ts +394 -77
  250. package/src/runtime/stealth-cookies.ts +132 -0
  251. package/src/runtime/stealth.ts +675 -289
  252. package/src/runtime/stt.ts +1 -19
  253. package/src/runtime/timeout.ts +18 -0
  254. package/src/runtime/trace-config.ts +77 -0
  255. package/src/serve.ts +6 -1
  256. package/src/server/index.ts +37 -2
  257. package/src/server/self-test-input-tokens.ts +29 -14
  258. package/src/server/self-test.ts +1025 -175
  259. package/src/server/serve-implementation.ts +3250 -0
  260. package/src/server/serve.ts +1 -1626
  261. package/src/server/trace-output.ts +32 -0
  262. package/src/server/types.ts +13 -1
  263. package/src/stateful/README.md +146 -0
  264. package/src/stateful/errors.ts +35 -0
  265. package/src/stateful/http-provider-event-emitter.ts +314 -0
  266. package/src/stateful/http-session-owner-registry.ts +306 -0
  267. package/src/stateful/index.ts +18 -0
  268. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  269. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  270. package/src/stateful/provider-event-pipeline.ts +61 -0
  271. package/src/stateful/provider-events.ts +462 -0
  272. package/src/stateful/session-key.ts +111 -0
  273. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  274. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  275. package/src/stateful/stateful-provider-adapter.ts +562 -0
  276. package/src/stateful/stateful-provider-observability.ts +261 -0
  277. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  278. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  279. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  280. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  281. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  282. package/src/stateful-signing.ts +46 -0
  283. package/src/stealth/profiles.ts +17 -7
  284. package/src/stream-evidence.ts +988 -0
  285. package/src/stream.ts +8 -1
  286. package/src/testing/index.ts +10 -1
  287. package/src/testing/run.ts +658 -15
  288. package/src/trace-sanitization.ts +63 -0
  289. package/src/types.ts +660 -38
  290. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  291. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  292. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  293. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  294. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  295. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  296. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  297. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  298. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  299. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  300. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  301. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  302. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  303. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+ import { TURN_KINDS } from "../auth-turn/index.js";
3
4
  import { Hono } from "hono";
4
5
  import { z } from "zod";
5
6
  import { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
@@ -27,6 +28,36 @@ export const SelfTestRequestSchema = z.object({
27
28
  /** Credential material for requiresConnection cases; never persisted. */
28
29
  credentials: z.object({ inputs: z.record(z.string(), z.string()) }).optional(),
29
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));
30
61
  function resolveSdkVersion() {
31
62
  try {
32
63
  const packageJsonUrl = new URL("../../package.json", import.meta.url);
@@ -101,8 +132,8 @@ export function isSelfTestReadOnlyOperation(operation) {
101
132
  }
102
133
  /** Binds the self-test executor to a tenant app's /v1 pipeline in-process. */
103
134
  export function createSelfTestInvoke(app) {
104
- return async ({ operationId, input, connection, requestId }) => {
105
- const response = await app.request(`/v1/${encodeURIComponent(operationId)}`, {
135
+ return async ({ operationId, input, connection, requestId, signal }) => {
136
+ const responsePromise = Promise.resolve(app.request(`/v1/${encodeURIComponent(operationId)}`, {
106
137
  method: "POST",
107
138
  headers: { "content-type": "application/json" },
108
139
  body: JSON.stringify({
@@ -110,7 +141,14 @@ export function createSelfTestInvoke(app) {
110
141
  input: input ?? {},
111
142
  ...(connection ? { connection } : {}),
112
143
  }),
113
- });
144
+ ...(signal ? { signal } : {}),
145
+ }));
146
+ const response = await responsePromise;
147
+ if (signal?.aborted) {
148
+ await cancelSelfTestResponse(response);
149
+ signal.throwIfAborted();
150
+ }
151
+ // An abort after body consumption starts may let response.text() finish.
114
152
  const text = await response.text();
115
153
  let body = text;
116
154
  try {
@@ -130,25 +168,92 @@ export function createSelfTestInvoke(app) {
130
168
  return { status: response.status, data: body };
131
169
  };
132
170
  }
171
+ async function cancelSelfTestResponse(response) {
172
+ await response.body?.cancel().catch(() => undefined);
173
+ }
174
+ function selfTestAbortReason(reason, sensitiveValues) {
175
+ const text = reason instanceof Error
176
+ ? reason.message || reason.name
177
+ : reason === undefined
178
+ ? "aborted"
179
+ : String(reason);
180
+ return redactSelfTestText(text, sensitiveValues);
181
+ }
182
+ function logSelfTestCancellation(provider, options, requestId, selected, signal, sensitiveValues) {
183
+ const event = {
184
+ level: "info",
185
+ event: "self_test_run_cancelled",
186
+ providerId: provider.id,
187
+ requestId,
188
+ operationId: selected.operationId,
189
+ caseName: selected.healthCase.name,
190
+ reason: selfTestAbortReason(signal.reason, sensitiveValues),
191
+ };
192
+ if (options.logger) {
193
+ options.logger(event);
194
+ return;
195
+ }
196
+ console.log(JSON.stringify(event));
197
+ }
198
+ /** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
199
+ export function createSelfTestAuthFlowInvoke(app) {
200
+ return async ({ route, requestId, flowId, connectionId, externalRef, input, context, signal, }) => {
201
+ const response = await app.request(`/auth/${route}`, {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({
205
+ requestId,
206
+ flowId,
207
+ ...(connectionId ? { connectionId } : {}),
208
+ ...(externalRef ? { externalRef } : {}),
209
+ ...(input ? { input } : {}),
210
+ ...(context ? { context } : {}),
211
+ }),
212
+ ...(signal ? { signal } : {}),
213
+ });
214
+ const text = await response.text();
215
+ let body = text;
216
+ try {
217
+ body = text.length > 0 ? JSON.parse(text) : undefined;
218
+ }
219
+ catch {
220
+ // non-JSON transports keep the raw text as body
221
+ }
222
+ return { status: response.status, body };
223
+ };
224
+ }
133
225
  class SelfTestCaseTimeoutError extends Error {
134
226
  constructor(timeoutMs) {
135
227
  super(`Self-test case timed out after ${timeoutMs}ms`);
136
228
  this.name = "SelfTestCaseTimeoutError";
137
229
  }
138
230
  }
139
- async function withCaseTimeout(run, timeoutMs) {
231
+ async function withCaseTimeout(run, timeoutMs, controller) {
140
232
  let timer;
233
+ let onAbort;
141
234
  try {
142
235
  return await Promise.race([
143
236
  run(),
144
237
  new Promise((_, reject) => {
145
- timer = setTimeout(() => reject(new SelfTestCaseTimeoutError(timeoutMs)), timeoutMs);
238
+ timer = setTimeout(() => {
239
+ const error = new SelfTestCaseTimeoutError(timeoutMs);
240
+ controller.abort(error);
241
+ reject(error);
242
+ }, timeoutMs);
243
+ }),
244
+ new Promise((_, reject) => {
245
+ onAbort = () => reject(controller.signal.reason);
246
+ controller.signal.addEventListener("abort", onAbort, { once: true });
247
+ if (controller.signal.aborted)
248
+ onAbort();
146
249
  }),
147
250
  ]);
148
251
  }
149
252
  finally {
150
253
  if (timer !== undefined)
151
254
  clearTimeout(timer);
255
+ if (onAbort)
256
+ controller.signal.removeEventListener("abort", onAbort);
152
257
  }
153
258
  }
154
259
  function objectProperty(value, key) {
@@ -171,6 +276,21 @@ function upstreamErrorMessage(body) {
171
276
  const message = objectProperty(error, "message");
172
277
  return typeof message === "string" ? message : undefined;
173
278
  }
279
+ /**
280
+ * How long a memoized multi-turn flow outcome suppresses re-driving the auth
281
+ * flow. Generous on purpose: a multi-turn ceremony (OTP, device approval) is a
282
+ * provider property that changes on the timescale of releases, not probe
283
+ * cycles, and every re-drive is a REAL upstream login submission. The cache is
284
+ * in-process, so a pod restart also clears the entry.
285
+ */
286
+ export const SELF_TEST_MULTI_TURN_RETRY_AFTER_MS = 24 * 60 * 60 * 1000;
287
+ /**
288
+ * Age bound for POSITIVE cached credentials. Expiry modes that never produce
289
+ * a 401/403 (a 200 login page, an assertion failure) would otherwise replay
290
+ * the same stale session until pod restart — one re-login per day is the
291
+ * upstream-safe recovery for them.
292
+ */
293
+ export const SELF_TEST_CREDENTIAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
174
294
  function resolveCaseTimeoutMs(execution, suite, healthCase) {
175
295
  const providerDefault = (execution.provider.healthProbe ?? execution.provider.healthMonitor)
176
296
  ?.defaultProbeTimeoutMs;
@@ -180,160 +300,646 @@ function resolveCaseTimeoutMs(execution, suite, healthCase) {
180
300
  providerDefault ??
181
301
  DEFAULT_CASE_TIMEOUT_MS);
182
302
  }
183
- function buildSelfTestConnection(execution, operationId, suite) {
303
+ function credentialSessionCacheKey(providerId, inputs) {
304
+ const canonical = JSON.stringify(Object.keys(inputs)
305
+ .sort()
306
+ .map((key) => [key, inputs[key]]));
307
+ return `${providerId}:${createHash("sha256").update(canonical).digest("hex")}`;
308
+ }
309
+ function registerSensitiveValues(execution, values) {
310
+ for (const value of values) {
311
+ if (typeof value === "string" &&
312
+ value.length > 0 &&
313
+ !execution.sensitiveValues.includes(value)) {
314
+ execution.sensitiveValues.push(value);
315
+ }
316
+ }
317
+ }
318
+ function parseAuthFlowResponse(result) {
319
+ const errorEnvelope = objectProperty(result.body, "error");
320
+ if (result.status < 200 || result.status >= 300 || errorEnvelope !== undefined) {
321
+ const message = objectProperty(errorEnvelope, "message");
322
+ return {
323
+ ok: false,
324
+ code: "auth_flow_failed",
325
+ message: typeof message === "string"
326
+ ? message
327
+ : `Auth flow request failed with status ${result.status}`,
328
+ httpStatus: result.status,
329
+ };
330
+ }
331
+ const turnValue = objectProperty(result.body, "data");
332
+ const turnKind = objectProperty(turnValue, "kind");
333
+ if (typeof turnKind !== "string") {
334
+ return {
335
+ ok: false,
336
+ code: "auth_flow_failed",
337
+ message: "Auth flow returned an unrecognized turn.",
338
+ httpStatus: result.status,
339
+ };
340
+ }
341
+ const contextPatch = objectProperty(result.body, "contextPatch");
342
+ return {
343
+ ok: true,
344
+ turn: {
345
+ kind: turnKind,
346
+ data: objectProperty(turnValue, "data"),
347
+ expectedInput: objectProperty(turnValue, "expectedInput"),
348
+ },
349
+ ...(contextPatch && typeof contextPatch === "object" && !Array.isArray(contextPatch)
350
+ ? { contextPatch: contextPatch }
351
+ : {}),
352
+ };
353
+ }
354
+ function applyAuthFlowContextPatch(base, patch) {
355
+ if (!patch)
356
+ return base;
357
+ const next = { ...base };
358
+ for (const [key, value] of Object.entries(patch)) {
359
+ if (value === null) {
360
+ delete next[key];
361
+ }
362
+ else {
363
+ next[key] = value;
364
+ }
365
+ }
366
+ return next;
367
+ }
368
+ /**
369
+ * Extracts the completed credential from a complete turn's data payload — the
370
+ * same `data.credential` record the gateway persists as connection secrets in
371
+ * production (`persistCredential` → credential-service `UpdateCredential`).
372
+ */
373
+ function completedCredentialFromTurn(turnData) {
374
+ const credential = objectProperty(turnData, "credential");
375
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
376
+ return undefined;
377
+ }
378
+ const secrets = {};
379
+ for (const [key, value] of Object.entries(credential)) {
380
+ if (typeof value === "string")
381
+ secrets[key] = value;
382
+ }
383
+ return Object.keys(secrets).length > 0 ? secrets : undefined;
384
+ }
385
+ /**
386
+ * Drives the provider's declared auth flow exactly like production does:
387
+ * `flow.start()` then a single `flow.continue(credentialInputs)`. Anything
388
+ * other than a complete turn is a visible multi-turn gap, never a fabricated
389
+ * probe failure.
390
+ */
391
+ /**
392
+ * Fields an input-prompt turn actually requests. The canonical auth-turn
393
+ * shape carries the JSON schema DIRECTLY on `expectedInput` (`ctx.auth
394
+ * .nextForm`/`defineCredentialsAuth`, the committed fixtures); some providers
395
+ * nest it as `expectedInput.schema`. Both are honored. `null` when the turn
396
+ * declares no schema (legacy/loose flows keep full-input semantics).
397
+ */
398
+ function turnRequestedFields(turn) {
399
+ const expectedInput = turn.expectedInput;
400
+ if (!expectedInput || typeof expectedInput !== "object" || Array.isArray(expectedInput)) {
401
+ return null;
402
+ }
403
+ const schemaOf = (candidate) => {
404
+ const properties = candidate && typeof candidate === "object"
405
+ ? candidate.properties
406
+ : undefined;
407
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
408
+ return null;
409
+ }
410
+ const requiredRaw = candidate && typeof candidate === "object"
411
+ ? candidate.required
412
+ : undefined;
413
+ const required = Array.isArray(requiredRaw)
414
+ ? requiredRaw.filter((field) => typeof field === "string")
415
+ : [];
416
+ return { properties: Object.keys(properties), required };
417
+ };
418
+ return (schemaOf(expectedInput) ?? schemaOf(expectedInput.schema));
419
+ }
420
+ async function materializeFlowCredential(execution, inputs, options = {}) {
421
+ const authFlow = execution.authFlow;
422
+ if (!authFlow) {
423
+ return {
424
+ kind: "flow_error",
425
+ code: "auth_flow_unavailable",
426
+ message: "Provider declares a credentials auth flow but the self-test host has no auth-flow driver.",
427
+ };
428
+ }
429
+ const flowId = `self-test-${randomUUID()}`;
430
+ // The login must ride the SAME proxy/connection affinity the probe will
431
+ // use (createAuthFlowContext keys affinity on connectionId) — otherwise
432
+ // IP/session-bound upstreams see the cookie arrive from a different
433
+ // session and reject it.
434
+ const started = parseAuthFlowResponse(await authFlow({
435
+ route: "start",
436
+ requestId: `${execution.requestId}-auth-start-${randomUUID()}`,
437
+ flowId,
438
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
439
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
440
+ ...(options.signal ? { signal: options.signal } : {}),
441
+ }));
442
+ if (!started.ok) {
443
+ return { kind: "flow_error", code: started.code, message: started.message };
444
+ }
445
+ let turn = started.turn;
446
+ const flowContext = applyAuthFlowContextPatch({}, started.contextPatch);
447
+ if (turn.kind === "abort") {
448
+ // Terminal turn: continuing after an abort would replay credentials into
449
+ // a flow that already refused to proceed. Not memoized (flow errors are
450
+ // never cached) — an abort can be transient upstream maintenance.
451
+ return {
452
+ kind: "flow_error",
453
+ code: "auth_flow_aborted",
454
+ message: "Auth flow aborted before requesting input.",
455
+ };
456
+ }
457
+ if (turn.kind !== "complete") {
458
+ // Validate the start turn BEFORE submitting credentials: an unknown
459
+ // kind may be a provider typo or a stage that must not receive the
460
+ // probe inputs. `retry` counts as an input prompt at this stage.
461
+ if (turn.kind !== "retry" && !INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
462
+ return {
463
+ kind: "flow_error",
464
+ code: "auth_flow_unexpected_turn",
465
+ message: `Auth flow start returned an unrecognized turn kind "${turn.kind}".`,
466
+ };
467
+ }
468
+ // Auto-continue ONLY into input prompts (form/retry). Other known
469
+ // interactive stages (redirect, poll, pending, challenge, message,
470
+ // multi_choice) are valid flows that are NOT asking for the credential
471
+ // inputs — posting the password there submits it to the wrong stage.
472
+ // They are a genuine headless gap: the memoized multi-turn skip.
473
+ if (turn.kind !== "form" && turn.kind !== "retry") {
474
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
475
+ }
476
+ // Submit ONLY what the turn asks for: a first stage of a multi-step
477
+ // login may request a subset (or different fields entirely) — posting
478
+ // the full inputs would send secrets to the wrong stage. Only the
479
+ // schema's REQUIRED fields are mandatory (defineCredentialsAuth
480
+ // encodes optional fields by omitting them from `required`); a turn
481
+ // whose required fields we do not hold is a headless gap (multi-turn).
482
+ // A turn with no declared schema keeps full-input semantics.
483
+ const requestedFields = turnRequestedFields(turn);
484
+ let submitInputs = { ...inputs };
485
+ if (requestedFields !== null) {
486
+ if (requestedFields.required.some((field) => inputs[field] === undefined)) {
487
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
488
+ }
489
+ submitInputs = Object.fromEntries(requestedFields.properties
490
+ .filter((field) => inputs[field] !== undefined)
491
+ .map((field) => [field, inputs[field]]));
492
+ }
493
+ // The case deadline may have fired while start() was still running.
494
+ // Never submit real credentials into a flow whose case already
495
+ // reported self_test_timeout — a late continue is a real upstream
496
+ // login/OTP attempt nobody is waiting for.
497
+ if (options.isAbandoned?.() === true) {
498
+ return {
499
+ kind: "flow_error",
500
+ code: "self_test_timeout",
501
+ message: "Case deadline passed before credential submission; flow abandoned.",
502
+ };
503
+ }
504
+ const continued = parseAuthFlowResponse(await authFlow({
505
+ route: "continue",
506
+ requestId: `${execution.requestId}-auth-continue-${randomUUID()}`,
507
+ flowId,
508
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
509
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
510
+ input: submitInputs,
511
+ ...(Object.keys(flowContext).length > 0 ? { context: flowContext } : {}),
512
+ ...(options.signal ? { signal: options.signal } : {}),
513
+ }));
514
+ if (!continued.ok) {
515
+ // Providers built with defineCredentialsAuth cannot return a retry
516
+ // turn — a rejected password THROWS and /auth/continue answers with
517
+ // an auth-shaped 401/403. That is a credential REJECTION (memoized,
518
+ // so the probe never hammers a locked-out login); every other
519
+ // status stays an uncached transient retry.
520
+ if (AUTH_REJECTION_HTTP_STATUSES.has(continued.httpStatus)) {
521
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
522
+ }
523
+ return { kind: "flow_error", code: continued.code, message: continued.message };
524
+ }
525
+ turn = continued.turn;
526
+ }
527
+ if (turn.kind === "abort") {
528
+ return {
529
+ kind: "flow_error",
530
+ code: "auth_flow_aborted",
531
+ message: "Auth flow aborted after credential submission.",
532
+ };
533
+ }
534
+ if (turn.kind === "retry") {
535
+ // A retry turn AFTER submission is a credential rejection, not an
536
+ // interactive gap — surfaced distinctly so monitoring can treat it as
537
+ // a real outage, and memoized by the caller (lockout safety).
538
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
539
+ }
540
+ if (turn.kind !== "complete") {
541
+ // Only KNOWN interactive kinds are a genuine "cannot complete headless"
542
+ // multi-turn gap (memoized by the caller). An unknown kind is ambiguous
543
+ // — it may encode a transient provider failure — so it reports as a
544
+ // flow error, which is never memoized, instead of freezing the signal.
545
+ if (!INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
546
+ return {
547
+ kind: "flow_error",
548
+ code: "auth_flow_unexpected_turn",
549
+ message: `Auth flow returned an unrecognized turn kind "${turn.kind}".`,
550
+ };
551
+ }
552
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
553
+ }
554
+ const credential = completedCredentialFromTurn(turn.data);
555
+ if (!credential) {
556
+ return {
557
+ kind: "flow_error",
558
+ code: "auth_flow_invalid_credential",
559
+ message: "Auth flow completed without a string-valued credential payload.",
560
+ };
561
+ }
562
+ // Redaction contract: flow-issued secrets are registered BEFORE any probe
563
+ // output can be built from them.
564
+ registerSensitiveValues(execution, Object.values(credential));
565
+ return { credential };
566
+ }
567
+ async function resolveSelfTestConnection(execution, operationId, suite, options = {}) {
184
568
  if (!suite.requiresConnection)
185
- return {};
569
+ return { kind: "connection" };
186
570
  const inputs = execution.credentials ?? {};
187
571
  const declaredFields = Object.keys((execution.provider.healthProbe ?? execution.provider.healthMonitor)?.credentialInputs ?? {});
188
572
  for (const field of declaredFields) {
189
573
  if (!inputs[field]) {
190
- return { skipReason: `credential_missing:${field}` };
574
+ return { kind: "skip", skipReason: `credential_missing:${field}` };
191
575
  }
192
576
  }
193
577
  if (declaredFields.length === 0 && Object.keys(inputs).length === 0) {
194
- return { skipReason: "credential_missing:credentials" };
578
+ return { kind: "skip", skipReason: "credential_missing:credentials" };
579
+ }
580
+ // The connection id seeds proxy/connection affinity in the provider
581
+ // context, so it must be STABLE per (provider, credentialInputs): a cached
582
+ // session replayed under a per-request id would ride a different proxy/IP
583
+ // each cycle and upstreams would treat the cookie as stale or suspicious.
584
+ // The id carries only a hash of the inputs, never the inputs themselves.
585
+ //
586
+ // Providers declaring `proxy.session.affinity: "operation"` pin the PROBE's
587
+ // proxy to `${providerId}/${operationId}` regardless of connection id — so
588
+ // the login must ride that exact key, and the session cache splits per
589
+ // operation (one shared cookie would otherwise hop between per-operation
590
+ // proxies).
591
+ const operationAffinity = typeof execution.provider.proxy === "object" &&
592
+ execution.provider.proxy?.session?.affinity === "operation";
593
+ const credentialKey = credentialSessionCacheKey(execution.provider.id, inputs);
594
+ const affinityKey = operationAffinity ? `${credentialKey}:${operationId}` : credentialKey;
595
+ // ONE id for the auth flow AND the probe connection: providers may bind
596
+ // the issued credential to FlowContext.connectionId and later compare it
597
+ // against ctx.request.connectionId. Operation-affinity providers use the
598
+ // probe's exact proxy key (providerId/operationId); everyone else uses the
599
+ // stable per-credential hash.
600
+ const connectionId = operationAffinity
601
+ ? `${execution.provider.id}/${operationId}`
602
+ : `self-test-${createHash("sha256").update(affinityKey).digest("hex").slice(0, 22)}`;
603
+ const buildConnection = (secrets) => ({
604
+ id: connectionId,
605
+ mode: "credentials",
606
+ secrets: { ...secrets },
607
+ metadata: { purpose: "provider-self-test", operationId },
608
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
609
+ });
610
+ const auth = execution.provider.auth;
611
+ if (auth?.mode !== "credentials" || !auth.flow) {
612
+ // Providers without a declared credentials flow keep raw-input semantics
613
+ // — and the pre-existing per-request connection id: there is no session
614
+ // to keep on one affinity, and a stable id would silently pin every
615
+ // cycle of a connection-affinity proxy to the same upstream session.
616
+ return {
617
+ kind: "connection",
618
+ connection: {
619
+ ...buildConnection(inputs),
620
+ id: `self-test-${execution.requestId}`,
621
+ },
622
+ credentialSource: "inputs",
623
+ };
624
+ }
625
+ const cacheKey = affinityKey;
626
+ const cached = execution.sessionCache.get(cacheKey);
627
+ // DR-7 upstream-account safety: a memoized multi-turn outcome
628
+ // short-circuits to the auth_flow_multi_turn skip WITHOUT re-driving
629
+ // flow.start()/flow.continue() — every re-drive is a real upstream login
630
+ // submission (OTP sends, lockout risk), and the probe scheduler would
631
+ // otherwise repeat it every cycle forever. Changed credentialInputs hash
632
+ // to a different key and re-attempt immediately; otherwise the entry
633
+ // expires after a generous TTL (or process restart) so a provider whose
634
+ // flow becomes single-turn again is eventually re-probed.
635
+ if (cached?.kind === "multi_turn" || cached?.kind === "rejected") {
636
+ if (Date.now() - cached.cachedAtMs < SELF_TEST_MULTI_TURN_RETRY_AFTER_MS) {
637
+ return {
638
+ kind: "skip",
639
+ skipReason: cached.kind === "rejected"
640
+ ? SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON
641
+ : SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON,
642
+ };
643
+ }
644
+ execution.sessionCache.delete(cacheKey);
645
+ }
646
+ if (options.forceLogin !== true && cached?.kind === "credential") {
647
+ if (Date.now() - cached.cachedAtMs >= SELF_TEST_CREDENTIAL_MAX_AGE_MS) {
648
+ // Age-bounded: expiry modes that never 401 (login-page 200s,
649
+ // assertion failures) must not replay one stale session forever.
650
+ execution.sessionCache.delete(cacheKey);
651
+ }
652
+ else {
653
+ registerSensitiveValues(execution, Object.values(cached.credential));
654
+ return {
655
+ kind: "connection",
656
+ connection: buildConnection(cached.credential),
657
+ credentialSource: "cache",
658
+ cacheKey,
659
+ };
660
+ }
661
+ }
662
+ const materialized = await materializeFlowCredential(execution, inputs, {
663
+ ...(options.isAbandoned !== undefined ? { isAbandoned: options.isAbandoned } : {}),
664
+ connectionId,
665
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
666
+ ...(options.signal ? { signal: options.signal } : {}),
667
+ });
668
+ if (!("credential" in materialized)) {
669
+ // Only the multi-turn SKIP is negative-cached. Flow ERRORS
670
+ // (auth_flow_unavailable / auth_flow_failed / invalid credential
671
+ // payloads, or a thrown start/continue) are never memoized: they are
672
+ // typically transient upstream or host failures, so each cycle may
673
+ // retry — permanently caching an error would silently freeze the
674
+ // signal on a blip, while retrying a FAILED request is not a repeated
675
+ // successful login submission.
676
+ if (materialized.kind === "skip" && options.isAbandoned?.() !== true) {
677
+ if (materialized.skipReason === SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON) {
678
+ execution.sessionCache.set(cacheKey, { kind: "multi_turn", cachedAtMs: Date.now() });
679
+ }
680
+ else if (materialized.skipReason === SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON) {
681
+ execution.sessionCache.set(cacheKey, { kind: "rejected", cachedAtMs: Date.now() });
682
+ }
683
+ }
684
+ return materialized;
195
685
  }
686
+ // Abort is cooperative: a flow may still complete after the case deadline
687
+ // if provider code ignores the signal. Never cache that late credential or
688
+ // let the next probe hide the timed-out login.
689
+ if (options.isAbandoned?.() === true) {
690
+ return {
691
+ kind: "flow_error",
692
+ code: "self_test_timeout",
693
+ message: "Auth flow completed after the case deadline; credential discarded.",
694
+ };
695
+ }
696
+ execution.sessionCache.set(cacheKey, {
697
+ kind: "credential",
698
+ credential: materialized.credential,
699
+ cachedAtMs: Date.now(),
700
+ });
196
701
  return {
197
- connection: {
198
- id: `self-test-${execution.requestId}`,
199
- mode: "credentials",
200
- secrets: { ...inputs },
201
- metadata: { purpose: "provider-self-test", operationId },
202
- externalRef: `${execution.provider.id}-${operationId}-self-test`,
203
- },
702
+ kind: "connection",
703
+ connection: buildConnection(materialized.credential),
704
+ credentialSource: "flow",
705
+ cacheKey,
204
706
  };
205
707
  }
206
- async function executeSelfTestCase(execution, operationId, suite, healthCase) {
207
- const { provider, invoke, sensitiveValues } = execution;
208
- const redact = (text) => redactSelfTestText(text, sensitiveValues);
209
- const startedAt = new Date().toISOString();
210
- const startedAtMs = performance.now();
211
- const finish = (partial) => ({
212
- operationId,
213
- caseName: healthCase.name,
214
- startedAt,
215
- finishedAt: new Date().toISOString(),
216
- responseTimeMs: partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
217
- ...partial,
218
- });
708
+ /** A failed probe whose HTTP status is auth-shaped invalidates a cached session once. */
709
+ function isAuthFailureCaseResult(result) {
710
+ return result.status === "failed" && (result.httpStatus === 401 || result.httpStatus === 403);
711
+ }
712
+ async function executeSelfTestCase(execution, operationId, suite, healthCase, caseController) {
713
+ const { provider, invoke } = execution;
714
+ // execution.sensitiveValues may grow while the case runs (flow-issued
715
+ // secrets); redact always reads the live array.
716
+ const redact = (text) => redactSelfTestText(text, execution.sensitiveValues);
219
717
  const defaultLabel = redact(healthCase.description ?? healthCase.name);
718
+ const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
719
+ // One deadline for the WHOLE case: connection materialization (auth flow),
720
+ // the probe, and the one-shot auth retry all draw from the same budget —
721
+ // a 30s case must never take ~4×30s across its stages.
722
+ const caseDeadlineAtMs = performance.now() + timeoutMs;
723
+ const remainingCaseTimeoutMs = () => Math.max(1, Math.ceil(caseDeadlineAtMs - performance.now()));
724
+ const runWithCaseTimeout = (run, remainingMs) => withCaseTimeout(run, remainingMs, caseController);
725
+ const beginCase = () => {
726
+ const startedAt = new Date().toISOString();
727
+ const startedAtMs = performance.now();
728
+ return {
729
+ startedAtMs,
730
+ finish: (partial) => ({
731
+ operationId,
732
+ caseName: healthCase.name,
733
+ startedAt,
734
+ finishedAt: new Date().toISOString(),
735
+ responseTimeMs: partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
736
+ ...partial,
737
+ }),
738
+ };
739
+ };
740
+ const caseScope = beginCase();
220
741
  if (healthCase.enabled && healthCase.enabled() === false) {
221
- return finish({
742
+ return caseScope.finish({
222
743
  status: "skipped",
223
744
  label: defaultLabel,
224
745
  skipReason: "disabled",
225
746
  });
226
747
  }
227
- const connectionResolution = buildSelfTestConnection(execution, operationId, suite);
228
- if ("skipReason" in connectionResolution) {
229
- return finish({
230
- status: "skipped",
748
+ const resolveConnection = async (forceLogin) => {
749
+ // The deadline aborts the in-process flow request. Keep the abandonment
750
+ // guard as a backstop for provider code that ignores cancellation so a
751
+ // late completion still cannot write the session cache.
752
+ let abandoned = false;
753
+ try {
754
+ return await runWithCaseTimeout(() => resolveSelfTestConnection(execution, operationId, suite, {
755
+ forceLogin,
756
+ isAbandoned: () => abandoned,
757
+ signal: caseController.signal,
758
+ }), remainingCaseTimeoutMs());
759
+ }
760
+ catch (error) {
761
+ abandoned = true;
762
+ return {
763
+ kind: "flow_error",
764
+ code: error instanceof SelfTestCaseTimeoutError ? "self_test_timeout" : "auth_flow_failed",
765
+ message: error instanceof Error ? error.message : String(error),
766
+ };
767
+ }
768
+ };
769
+ const nonConnectionResult = (resolution) => {
770
+ if (resolution.kind === "skip") {
771
+ return caseScope.finish({
772
+ status: "skipped",
773
+ label: defaultLabel,
774
+ skipReason: resolution.skipReason,
775
+ });
776
+ }
777
+ return caseScope.finish({
778
+ status: "error",
231
779
  label: defaultLabel,
232
- skipReason: connectionResolution.skipReason,
780
+ error: { code: resolution.code, message: redact(resolution.message) },
233
781
  });
234
- }
235
- const connection = connectionResolution.connection;
236
- const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
237
- try {
238
- return await withCaseTimeout(async () => {
239
- const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
240
- const preparedInput = healthCase.prepareInput
241
- ? await healthCase.prepareInput({
242
- providerId: provider.id,
782
+ };
783
+ const runProbeAttempt = async (connection) => {
784
+ // gateway.execute keeps its contract — EVERY helper status is returned
785
+ // to the prepareInput hook (it may branch on 401 itself). The last
786
+ // auth-shaped helper status is only RECORDED: if the hook then throws,
787
+ // the case fails WITH that status so stale-session recovery triggers.
788
+ let prepareAuthStatus = null;
789
+ // Share the OUTER case scope: startedAt/responseTimeMs must cover the
790
+ // WHOLE case — auth-flow materialization included — not just the final
791
+ // operation attempt, or a slow login reads as a fast healthy case.
792
+ const { startedAtMs, finish } = caseScope;
793
+ try {
794
+ return await runWithCaseTimeout(async () => {
795
+ const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
796
+ const preparedInput = healthCase.prepareInput
797
+ ? await healthCase.prepareInput({
798
+ providerId: provider.id,
799
+ operationId,
800
+ input: resolvedInput,
801
+ ...(connection ? { connectionId: connection.id } : {}),
802
+ gateway: {
803
+ execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
804
+ if (foreignProviderId !== provider.id) {
805
+ throw new Error(`Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`);
806
+ }
807
+ const startedGatewayMs = performance.now();
808
+ const executed = await invoke({
809
+ operationId: gatewayOperationId,
810
+ input: gatewayInput,
811
+ connection,
812
+ requestId: `${execution.requestId}-prepare-${randomUUID()}`,
813
+ signal: caseController.signal,
814
+ });
815
+ if (executed.status === 401 || executed.status === 403) {
816
+ prepareAuthStatus = executed.status;
817
+ }
818
+ return {
819
+ status: executed.status,
820
+ duration: performance.now() - startedGatewayMs,
821
+ data: executed.data,
822
+ meta: executed.meta,
823
+ };
824
+ },
825
+ },
826
+ })
827
+ : resolvedInput;
828
+ const executed = await invoke({
243
829
  operationId,
244
- input: resolvedInput,
245
- ...(connection ? { connectionId: connection.id } : {}),
246
- gateway: {
247
- execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
248
- if (foreignProviderId !== provider.id) {
249
- throw new Error(`Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`);
250
- }
251
- const startedGatewayMs = performance.now();
252
- const executed = await invoke({
253
- operationId: gatewayOperationId,
254
- input: gatewayInput,
255
- connection,
256
- requestId: `${execution.requestId}-prepare-${randomUUID()}`,
257
- });
258
- return {
259
- status: executed.status,
260
- duration: performance.now() - startedGatewayMs,
261
- data: executed.data,
262
- meta: executed.meta,
263
- };
830
+ input: preparedInput,
831
+ connection,
832
+ requestId: `${execution.requestId}-${randomUUID()}`,
833
+ signal: caseController.signal,
834
+ });
835
+ const durationMs = performance.now() - startedAtMs;
836
+ if (executed.status < 200 || executed.status >= 300) {
837
+ return finish({
838
+ status: "failed",
839
+ label: defaultLabel,
840
+ httpStatus: executed.status,
841
+ error: {
842
+ code: upstreamErrorCode(executed.data) ?? "operation_failed",
843
+ message: redact(upstreamErrorMessage(executed.data) ??
844
+ `Operation invocation failed with status ${executed.status}`),
264
845
  },
265
- },
266
- })
267
- : resolvedInput;
268
- const executed = await invoke({
269
- operationId,
270
- input: preparedInput,
271
- connection,
272
- requestId: `${execution.requestId}-${randomUUID()}`,
273
- });
274
- const durationMs = performance.now() - startedAtMs;
275
- if (executed.status < 200 || executed.status >= 300) {
846
+ });
847
+ }
848
+ const assertionContext = {
849
+ status: executed.status,
850
+ data: executed.data,
851
+ durationMs,
852
+ ...(executed.meta ? { meta: executed.meta } : {}),
853
+ };
854
+ let assertionResult;
855
+ try {
856
+ assertionResult = await healthCase.assertions(assertionContext);
857
+ }
858
+ catch (assertionError) {
859
+ return finish({
860
+ status: "failed",
861
+ label: defaultLabel,
862
+ httpStatus: executed.status,
863
+ assertion: {
864
+ passed: false,
865
+ message: redact(assertionError instanceof Error ? assertionError.message : String(assertionError)),
866
+ },
867
+ });
868
+ }
869
+ const statusValue = objectProperty(assertionResult, "status");
870
+ const overrideStatus = statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
871
+ const labelValue = objectProperty(assertionResult, "label");
872
+ const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
276
873
  return finish({
277
- status: "failed",
278
- label: defaultLabel,
874
+ status: overrideStatus ?? "ok",
875
+ label: overrideLabel ?? defaultLabel,
279
876
  httpStatus: executed.status,
280
- error: {
281
- code: upstreamErrorCode(executed.data) ?? "operation_failed",
282
- message: redact(upstreamErrorMessage(executed.data) ??
283
- `Operation invocation failed with status ${executed.status}`),
284
- },
877
+ assertion: { passed: true },
878
+ });
879
+ }, remainingCaseTimeoutMs());
880
+ }
881
+ catch (error) {
882
+ if (error instanceof SelfTestCaseTimeoutError) {
883
+ return finish({
884
+ status: "error",
885
+ label: defaultLabel,
886
+ error: { code: "self_test_timeout", message: redact(error.message) },
285
887
  });
286
888
  }
287
- const assertionContext = {
288
- status: executed.status,
289
- data: executed.data,
290
- durationMs,
291
- ...(executed.meta ? { meta: executed.meta } : {}),
292
- };
293
- let assertionResult;
294
- try {
295
- assertionResult = await healthCase.assertions(assertionContext);
296
- }
297
- catch (assertionError) {
889
+ if (prepareAuthStatus !== null) {
298
890
  return finish({
299
891
  status: "failed",
300
892
  label: defaultLabel,
301
- httpStatus: executed.status,
893
+ httpStatus: prepareAuthStatus,
302
894
  assertion: {
303
895
  passed: false,
304
- message: redact(assertionError instanceof Error ? assertionError.message : String(assertionError)),
896
+ message: redact(error instanceof Error ? error.message : String(error)),
305
897
  },
306
898
  });
307
899
  }
308
- const statusValue = objectProperty(assertionResult, "status");
309
- const overrideStatus = statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
310
- const labelValue = objectProperty(assertionResult, "label");
311
- const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
312
- return finish({
313
- status: overrideStatus ?? "ok",
314
- label: overrideLabel ?? defaultLabel,
315
- httpStatus: executed.status,
316
- assertion: { passed: true },
317
- });
318
- }, timeoutMs);
319
- }
320
- catch (error) {
321
- if (error instanceof SelfTestCaseTimeoutError) {
322
900
  return finish({
323
901
  status: "error",
324
902
  label: defaultLabel,
325
- error: { code: "self_test_timeout", message: redact(error.message) },
903
+ error: {
904
+ code: "self_test_execution_error",
905
+ message: redact(error instanceof Error ? error.message : String(error)),
906
+ },
326
907
  });
327
908
  }
328
- return finish({
329
- status: "error",
330
- label: defaultLabel,
331
- error: {
332
- code: "self_test_execution_error",
333
- message: redact(error instanceof Error ? error.message : String(error)),
334
- },
335
- });
909
+ };
910
+ const resolution = await resolveConnection(false);
911
+ if (resolution.kind !== "connection") {
912
+ return nonConnectionResult(resolution);
913
+ }
914
+ let result = await runProbeAttempt(resolution.connection);
915
+ // One-shot session recovery: a cached credential that fails the probe with
916
+ // an auth-shaped status is invalidated, the flow re-runs ONCE, and the
917
+ // probe retries once. Fresh (just-materialized) credentials never retry.
918
+ if (resolution.credentialSource === "cache" &&
919
+ resolution.cacheKey !== undefined &&
920
+ isAuthFailureCaseResult(result)) {
921
+ execution.sessionCache.delete(resolution.cacheKey);
922
+ const retryResolution = await resolveConnection(true);
923
+ if (retryResolution.kind !== "connection") {
924
+ return nonConnectionResult(retryResolution);
925
+ }
926
+ result = await runProbeAttempt(retryResolution.connection);
927
+ // The retry's fresh credential is subject to the same eviction rule
928
+ // as a first-attempt fresh credential (below).
929
+ if (retryResolution.cacheKey !== undefined && isAuthFailureCaseResult(result)) {
930
+ execution.sessionCache.delete(retryResolution.cacheKey);
931
+ }
932
+ return result;
933
+ }
934
+ // A FRESH credential the probe just rejected is known-bad: evict it so the
935
+ // next cycle logs in anew instead of replaying a guaranteed-stale session
936
+ // once before recovering. (No retry here — fresh credentials never retry.)
937
+ if (resolution.credentialSource === "flow" &&
938
+ resolution.cacheKey !== undefined &&
939
+ isAuthFailureCaseResult(result)) {
940
+ execution.sessionCache.delete(resolution.cacheKey);
336
941
  }
942
+ return result;
337
943
  }
338
944
  function selectCases(provider, request) {
339
945
  const singleCase = request.operationId !== undefined && request.caseName !== undefined;
@@ -420,6 +1026,10 @@ export function createSelfTestApp(provider, options) {
420
1026
  const app = new Hono();
421
1027
  const planDigest = computeSelfTestPlanDigest(provider);
422
1028
  const requestBudgetMs = resolveRequestBudgetMs(options);
1029
+ // In-process flow-credential session cache (providerId + credentialInputs
1030
+ // hash → materialized credential). Lives as long as the app so consecutive
1031
+ // probe cycles never log in to the upstream more than once per session.
1032
+ const sessionCache = new Map();
423
1033
  let busy = false;
424
1034
  app.notFound((c) => c.json({ error: { code: "not_found", message: "Not found" } }, 404));
425
1035
  app.get(SELF_TEST_HEALTHZ_PATH, (c) => c.json({ ok: true }));
@@ -486,6 +1096,7 @@ export function createSelfTestApp(provider, options) {
486
1096
  const execution = {
487
1097
  provider,
488
1098
  invoke: options.invoke,
1099
+ ...(options.authFlow ? { authFlow: options.authFlow } : {}),
489
1100
  requestId: request.requestId,
490
1101
  credentials: request.credentials?.inputs,
491
1102
  requestTimeoutMs: request.timeoutMs,
@@ -493,44 +1104,69 @@ export function createSelfTestApp(provider, options) {
493
1104
  env: options.env,
494
1105
  credentialInputs: request.credentials?.inputs,
495
1106
  }),
1107
+ sessionCache,
496
1108
  };
497
1109
  const deadline = performance.now() + requestBudgetMs;
498
1110
  const results = [];
1111
+ const runSignal = c.req.raw.signal;
1112
+ let interruptedCase;
499
1113
  // Sequential execution (parallelism 1): self-tests run on serving pods
500
1114
  // and must never compete with themselves for upstream quota.
501
1115
  for (const selected of selection.cases) {
502
- if (performance.now() >= deadline) {
503
- const now = new Date().toISOString();
504
- results.push({
505
- operationId: selected.operationId,
506
- caseName: selected.healthCase.name,
507
- status: "skipped",
508
- label: selected.healthCase.name,
509
- responseTimeMs: 0,
510
- skipReason: "budget_exhausted",
511
- startedAt: now,
512
- finishedAt: now,
513
- });
514
- continue;
1116
+ const caseController = new AbortController();
1117
+ const abortFromRun = () => caseController.abort(runSignal.reason);
1118
+ runSignal.addEventListener("abort", abortFromRun, { once: true });
1119
+ if (runSignal.aborted)
1120
+ abortFromRun();
1121
+ try {
1122
+ if (runSignal.aborted) {
1123
+ interruptedCase = selected;
1124
+ break;
1125
+ }
1126
+ if (performance.now() >= deadline) {
1127
+ const now = new Date().toISOString();
1128
+ results.push({
1129
+ operationId: selected.operationId,
1130
+ caseName: selected.healthCase.name,
1131
+ status: "skipped",
1132
+ label: selected.healthCase.name,
1133
+ responseTimeMs: 0,
1134
+ skipReason: "budget_exhausted",
1135
+ startedAt: now,
1136
+ finishedAt: now,
1137
+ });
1138
+ continue;
1139
+ }
1140
+ if (!isSelfTestReadOnlyOperation(selected.operation)) {
1141
+ const now = new Date().toISOString();
1142
+ results.push({
1143
+ operationId: selected.operationId,
1144
+ caseName: selected.healthCase.name,
1145
+ status: "error",
1146
+ label: selected.healthCase.name,
1147
+ responseTimeMs: 0,
1148
+ error: {
1149
+ code: "operation_not_read_only",
1150
+ message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
1151
+ },
1152
+ startedAt: now,
1153
+ finishedAt: now,
1154
+ });
1155
+ continue;
1156
+ }
1157
+ results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase, caseController));
1158
+ if (runSignal.aborted) {
1159
+ interruptedCase = selected;
1160
+ break;
1161
+ }
515
1162
  }
516
- if (!isSelfTestReadOnlyOperation(selected.operation)) {
517
- const now = new Date().toISOString();
518
- results.push({
519
- operationId: selected.operationId,
520
- caseName: selected.healthCase.name,
521
- status: "error",
522
- label: selected.healthCase.name,
523
- responseTimeMs: 0,
524
- error: {
525
- code: "operation_not_read_only",
526
- message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
527
- },
528
- startedAt: now,
529
- finishedAt: now,
530
- });
531
- continue;
1163
+ finally {
1164
+ runSignal.removeEventListener("abort", abortFromRun);
1165
+ caseController.abort();
532
1166
  }
533
- results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase));
1167
+ }
1168
+ if (interruptedCase) {
1169
+ logSelfTestCancellation(provider, options, request.requestId, interruptedCase, runSignal, execution.sensitiveValues);
534
1170
  }
535
1171
  const singleCase = request.operationId !== undefined && request.caseName !== undefined;
536
1172
  const response = {