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

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 (51) hide show
  1. package/AUTHORING.md +92 -0
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +26 -1
  6. package/bin/apifuse-pack-check.ts +14 -0
  7. package/bin/apifuse-submit-check.ts +433 -15
  8. package/bin/apifuse-sync-assets.ts +117 -0
  9. package/dist/cli/commands.d.ts +1 -1
  10. package/dist/cli/commands.js +8 -0
  11. package/dist/cli/create.d.ts +3 -0
  12. package/dist/cli/create.js +34 -35
  13. package/dist/cli/prompt-assets.d.ts +80 -0
  14. package/dist/cli/prompt-assets.js +743 -0
  15. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/runtime/executor.js +7 -0
  19. package/dist/runtime/secrets.d.ts +27 -0
  20. package/dist/runtime/secrets.js +51 -0
  21. package/dist/server/index.d.ts +1 -1
  22. package/dist/server/index.js +1 -1
  23. package/dist/server/self-test.d.ts +101 -0
  24. package/dist/server/self-test.js +670 -112
  25. package/dist/server/serve.d.ts +5 -0
  26. package/dist/server/serve.js +41 -1
  27. package/package.json +1 -1
  28. package/src/cli/commands.ts +10 -0
  29. package/src/cli/create.ts +42 -35
  30. package/src/cli/prompt-assets.ts +865 -0
  31. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  32. package/src/index.ts +5 -0
  33. package/src/runtime/executor.ts +8 -0
  34. package/src/runtime/secrets.ts +64 -0
  35. package/src/server/index.ts +5 -0
  36. package/src/server/self-test.ts +852 -127
  37. package/src/server/serve.ts +60 -1
  38. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  39. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  40. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  41. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  42. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  43. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  44. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  45. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  46. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  47. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  48. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  49. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  50. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  51. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
 
4
+ import { TURN_KINDS } from "../auth-turn/index.js";
5
+
4
6
  import { type Context, Hono } from "hono";
5
7
  import { z } from "zod";
6
8
  import type {
@@ -86,11 +88,79 @@ export type SelfTestOperationInvoke = (args: {
86
88
  meta?: Record<string, unknown>;
87
89
  }>;
88
90
 
91
+ export type SelfTestAuthFlowRoute = "start" | "continue";
92
+
93
+ /**
94
+ * In-process driver for the tenant app's /auth pipeline. Self-test uses it to
95
+ * materialize `requiresConnection` credentials through the provider's declared
96
+ * auth flow — the exact path production connections take — instead of
97
+ * injecting raw credential inputs as connection secrets.
98
+ */
99
+ export type SelfTestAuthFlowInvoke = (args: {
100
+ route: SelfTestAuthFlowRoute;
101
+ requestId: string;
102
+ flowId: string;
103
+ /** Stable per-credential connection id — keeps login on the probe's affinity. */
104
+ connectionId?: string;
105
+ /** The probe connection's externalRef — flows reading ctx.externalRef see the same identity. */
106
+ externalRef?: string;
107
+ input?: Record<string, unknown>;
108
+ context?: Record<string, unknown>;
109
+ }) => Promise<{
110
+ status: number;
111
+ body: unknown;
112
+ }>;
113
+
114
+ /**
115
+ * Skip reason reported when a declared auth flow does not complete in a single
116
+ * continue (OTP, retry loop). Cross-repo contract: the health-monitor maps
117
+ * this exact string to `self_test_incapable`; never vary it.
118
+ */
119
+ export const SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON = "auth_flow_multi_turn";
120
+
121
+ /**
122
+ * A `retry` turn after credential submission: the flow REJECTED the
123
+ * configured inputs (bad password, exchange failure). Distinct from the
124
+ * multi-turn gap so monitoring surfaces it as a real credential outage, and
125
+ * memoized like multi-turn so the probe does not re-submit rejected
126
+ * credentials every cycle (lockout safety).
127
+ */
128
+ export const SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON = "auth_flow_rejected";
129
+
130
+ /**
131
+ * Known interactive turn kinds that justify the memoized multi-turn skip —
132
+ * they mean a human must participate (OTP, challenge, redirect, …).
133
+ * `retry` is deliberately excluded: after a credential submission it means
134
+ * rejection, not interaction (see SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON).
135
+ * Kinds outside TURN_KINDS entirely are treated as flow errors.
136
+ */
137
+ /**
138
+ * Post-submission /auth/continue statuses that mean the flow REJECTED the
139
+ * credentials (thrown AuthError -> 401, forbidden -> 403): memoized as
140
+ * `auth_flow_rejected`. Deliberately NOT 400 — the auth route maps generic
141
+ * ProviderErrors and Zod request errors there, which are often transient or
142
+ * fixable and must stay uncached retries (like 408/429/5xx).
143
+ */
144
+ const AUTH_REJECTION_HTTP_STATUSES: ReadonlySet<number> = new Set([401, 403]);
145
+
146
+ const INTERACTIVE_TURN_KIND_SET: ReadonlySet<string> = new Set(
147
+ TURN_KINDS.filter(
148
+ (descriptor) => descriptor.rendering !== "terminal" && descriptor.kind !== "retry",
149
+ ).map((descriptor) => descriptor.kind),
150
+ );
151
+
89
152
  export interface SelfTestAppOptions {
90
153
  /** Derived-token verification secrets; without them every self-test route 404s. */
91
154
  secrets?: SelfTestMasterSecrets;
92
155
  /** In-process invoke bound to the tenant-facing app's /v1 pipeline. */
93
156
  invoke: SelfTestOperationInvoke;
157
+ /**
158
+ * In-process auth-flow driver bound to the tenant-facing app's /auth
159
+ * pipeline. Required for providers that declare `auth.mode: "credentials"`
160
+ * with a flow; without it their requiresConnection cases report a visible
161
+ * auth_flow_unavailable error instead of probing with raw inputs.
162
+ */
163
+ authFlow?: SelfTestAuthFlowInvoke;
94
164
  /** Overall request budget; defaults to env / 120s. */
95
165
  requestBudgetMs?: number;
96
166
  /** Env override for secret collection + budget resolution (tests). */
@@ -207,6 +277,34 @@ export function createSelfTestInvoke(app: {
207
277
  };
208
278
  }
209
279
 
280
+ /** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
281
+ export function createSelfTestAuthFlowInvoke(app: {
282
+ request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
283
+ }): SelfTestAuthFlowInvoke {
284
+ return async ({ route, requestId, flowId, connectionId, externalRef, input, context }) => {
285
+ const response = await app.request(`/auth/${route}`, {
286
+ method: "POST",
287
+ headers: { "content-type": "application/json" },
288
+ body: JSON.stringify({
289
+ requestId,
290
+ flowId,
291
+ ...(connectionId ? { connectionId } : {}),
292
+ ...(externalRef ? { externalRef } : {}),
293
+ ...(input ? { input } : {}),
294
+ ...(context ? { context } : {}),
295
+ }),
296
+ });
297
+ const text = await response.text();
298
+ let body: unknown = text;
299
+ try {
300
+ body = text.length > 0 ? JSON.parse(text) : undefined;
301
+ } catch {
302
+ // non-JSON transports keep the raw text as body
303
+ }
304
+ return { status: response.status, body };
305
+ };
306
+ }
307
+
210
308
  class SelfTestCaseTimeoutError extends Error {
211
309
  constructor(timeoutMs: number) {
212
310
  super(`Self-test case timed out after ${timeoutMs}ms`);
@@ -250,13 +348,69 @@ function upstreamErrorMessage(body: unknown): string | undefined {
250
348
  return typeof message === "string" ? message : undefined;
251
349
  }
252
350
 
351
+ /**
352
+ * How long a memoized multi-turn flow outcome suppresses re-driving the auth
353
+ * flow. Generous on purpose: a multi-turn ceremony (OTP, device approval) is a
354
+ * provider property that changes on the timescale of releases, not probe
355
+ * cycles, and every re-drive is a REAL upstream login submission. The cache is
356
+ * in-process, so a pod restart also clears the entry.
357
+ */
358
+ export const SELF_TEST_MULTI_TURN_RETRY_AFTER_MS = 24 * 60 * 60 * 1000;
359
+
360
+ /**
361
+ * Age bound for POSITIVE cached credentials. Expiry modes that never produce
362
+ * a 401/403 (a 200 login page, an assertion failure) would otherwise replay
363
+ * the same stale session until pod restart — one re-login per day is the
364
+ * upstream-safe recovery for them.
365
+ */
366
+ export const SELF_TEST_CREDENTIAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
367
+
368
+ export type SelfTestCredentialSessionEntry =
369
+ /** Flow-materialized credential reused across probe cycles. */
370
+ | { kind: "credential"; credential: Record<string, string>; cachedAtMs: number }
371
+ /**
372
+ * Negative entry: the flow did not complete in a single continue turn
373
+ * (`auth_flow_multi_turn`). Memoized so subsequent cycles report the skip
374
+ * WITHOUT contacting the upstream again — the first attempt already
375
+ * submitted real credentials (and may have triggered an OTP send).
376
+ */
377
+ | { kind: "multi_turn"; cachedAtMs: number }
378
+ /**
379
+ * Negative entry: the flow REJECTED the submitted credential inputs
380
+ * (`retry` turn after continue — bad password, exchange failure).
381
+ * Memoized so the probe does not re-submit rejected credentials every
382
+ * cycle; a new entry is attempted when the inputs rotate (new hash),
383
+ * the TTL lapses, or the process restarts.
384
+ */
385
+ | { kind: "rejected"; cachedAtMs: number };
386
+
387
+ /**
388
+ * In-process cache of per-(providerId + stable hash of credentialInputs) auth
389
+ * flow outcomes, so consecutive probe cycles reuse the session — or the
390
+ * memoized multi-turn skip — instead of logging in every cycle (upstream
391
+ * account safety, DR-7). Credential entries are invalidated on a probe auth
392
+ * failure, at most once; multi-turn entries expire after
393
+ * `SELF_TEST_MULTI_TURN_RETRY_AFTER_MS` or on process restart. Flow ERRORS
394
+ * (transport/protocol failures, thrown start/continue) are deliberately NEVER
395
+ * cached: they are typically transient, and retrying a failed request next
396
+ * cycle is not a repeated login submission.
397
+ */
398
+ export type SelfTestCredentialSessionCache = Map<string, SelfTestCredentialSessionEntry>;
399
+
253
400
  interface SelfTestExecutionContext {
254
401
  provider: ProviderDefinition;
255
402
  invoke: SelfTestOperationInvoke;
403
+ authFlow?: SelfTestAuthFlowInvoke;
256
404
  requestId: string;
257
405
  credentials?: Readonly<Record<string, string>>;
258
406
  requestTimeoutMs?: number;
259
- sensitiveValues: readonly string[];
407
+ /**
408
+ * Mutable on purpose: every secret value materialized by an auth flow is
409
+ * appended here BEFORE any probe output is built, so redactSelfTestText
410
+ * scrubs flow-issued cookies/tokens exactly like request-supplied inputs.
411
+ */
412
+ sensitiveValues: string[];
413
+ sessionCache: SelfTestCredentialSessionCache;
260
414
  }
261
415
 
262
416
  function resolveCaseTimeoutMs(
@@ -275,188 +429,753 @@ function resolveCaseTimeoutMs(
275
429
  );
276
430
  }
277
431
 
278
- function buildSelfTestConnection(
432
+ function credentialSessionCacheKey(
433
+ providerId: string,
434
+ inputs: Readonly<Record<string, string>>,
435
+ ): string {
436
+ const canonical = JSON.stringify(
437
+ Object.keys(inputs)
438
+ .sort()
439
+ .map((key) => [key, inputs[key]]),
440
+ );
441
+ return `${providerId}:${createHash("sha256").update(canonical).digest("hex")}`;
442
+ }
443
+
444
+ function registerSensitiveValues(
445
+ execution: SelfTestExecutionContext,
446
+ values: Iterable<string>,
447
+ ): void {
448
+ for (const value of values) {
449
+ if (
450
+ typeof value === "string" &&
451
+ value.length > 0 &&
452
+ !execution.sensitiveValues.includes(value)
453
+ ) {
454
+ execution.sensitiveValues.push(value);
455
+ }
456
+ }
457
+ }
458
+
459
+ type SelfTestConnectionResolution =
460
+ | {
461
+ kind: "connection";
462
+ connection?: OperationConnection;
463
+ credentialSource?: "inputs" | "flow" | "cache";
464
+ cacheKey?: string;
465
+ }
466
+ | { kind: "skip"; skipReason: string }
467
+ | { kind: "flow_error"; code: string; message: string };
468
+
469
+ type ParsedAuthFlowTurn =
470
+ | {
471
+ ok: true;
472
+ turn: { kind: string; data?: unknown; expectedInput?: unknown };
473
+ contextPatch?: Record<string, unknown>;
474
+ }
475
+ | { ok: false; code: string; message: string; httpStatus: number };
476
+
477
+ function parseAuthFlowResponse(result: { status: number; body: unknown }): ParsedAuthFlowTurn {
478
+ const errorEnvelope = objectProperty(result.body, "error");
479
+ if (result.status < 200 || result.status >= 300 || errorEnvelope !== undefined) {
480
+ const message = objectProperty(errorEnvelope, "message");
481
+ return {
482
+ ok: false,
483
+ code: "auth_flow_failed",
484
+ message:
485
+ typeof message === "string"
486
+ ? message
487
+ : `Auth flow request failed with status ${result.status}`,
488
+ httpStatus: result.status,
489
+ };
490
+ }
491
+ const turnValue = objectProperty(result.body, "data");
492
+ const turnKind = objectProperty(turnValue, "kind");
493
+ if (typeof turnKind !== "string") {
494
+ return {
495
+ ok: false,
496
+ code: "auth_flow_failed",
497
+ message: "Auth flow returned an unrecognized turn.",
498
+ httpStatus: result.status,
499
+ };
500
+ }
501
+ const contextPatch = objectProperty(result.body, "contextPatch");
502
+ return {
503
+ ok: true,
504
+ turn: {
505
+ kind: turnKind,
506
+ data: objectProperty(turnValue, "data"),
507
+ expectedInput: objectProperty(turnValue, "expectedInput"),
508
+ },
509
+ ...(contextPatch && typeof contextPatch === "object" && !Array.isArray(contextPatch)
510
+ ? { contextPatch: contextPatch as Record<string, unknown> }
511
+ : {}),
512
+ };
513
+ }
514
+
515
+ function applyAuthFlowContextPatch(
516
+ base: Record<string, unknown>,
517
+ patch: Record<string, unknown> | undefined,
518
+ ): Record<string, unknown> {
519
+ if (!patch) return base;
520
+ const next = { ...base };
521
+ for (const [key, value] of Object.entries(patch)) {
522
+ if (value === null) {
523
+ delete next[key];
524
+ } else {
525
+ next[key] = value;
526
+ }
527
+ }
528
+ return next;
529
+ }
530
+
531
+ /**
532
+ * Extracts the completed credential from a complete turn's data payload — the
533
+ * same `data.credential` record the gateway persists as connection secrets in
534
+ * production (`persistCredential` → credential-service `UpdateCredential`).
535
+ */
536
+ function completedCredentialFromTurn(turnData: unknown): Record<string, string> | undefined {
537
+ const credential = objectProperty(turnData, "credential");
538
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
539
+ return undefined;
540
+ }
541
+ const secrets: Record<string, string> = {};
542
+ for (const [key, value] of Object.entries(credential)) {
543
+ if (typeof value === "string") secrets[key] = value;
544
+ }
545
+ return Object.keys(secrets).length > 0 ? secrets : undefined;
546
+ }
547
+
548
+ /**
549
+ * Drives the provider's declared auth flow exactly like production does:
550
+ * `flow.start()` then a single `flow.continue(credentialInputs)`. Anything
551
+ * other than a complete turn is a visible multi-turn gap, never a fabricated
552
+ * probe failure.
553
+ */
554
+
555
+ /**
556
+ * Fields an input-prompt turn actually requests. The canonical auth-turn
557
+ * shape carries the JSON schema DIRECTLY on `expectedInput` (`ctx.auth
558
+ * .nextForm`/`defineCredentialsAuth`, the committed fixtures); some providers
559
+ * nest it as `expectedInput.schema`. Both are honored. `null` when the turn
560
+ * declares no schema (legacy/loose flows keep full-input semantics).
561
+ */
562
+ function turnRequestedFields(
563
+ turn: { expectedInput?: unknown },
564
+ ): { properties: string[]; required: string[] } | null {
565
+ const expectedInput = turn.expectedInput;
566
+ if (!expectedInput || typeof expectedInput !== "object" || Array.isArray(expectedInput)) {
567
+ return null;
568
+ }
569
+ const schemaOf = (candidate: unknown): { properties: string[]; required: string[] } | null => {
570
+ const properties =
571
+ candidate && typeof candidate === "object"
572
+ ? (candidate as { properties?: unknown }).properties
573
+ : undefined;
574
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
575
+ return null;
576
+ }
577
+ const requiredRaw =
578
+ candidate && typeof candidate === "object"
579
+ ? (candidate as { required?: unknown }).required
580
+ : undefined;
581
+ const required = Array.isArray(requiredRaw)
582
+ ? requiredRaw.filter((field): field is string => typeof field === "string")
583
+ : [];
584
+ return { properties: Object.keys(properties), required };
585
+ };
586
+ return (
587
+ schemaOf(expectedInput) ?? schemaOf((expectedInput as { schema?: unknown }).schema)
588
+ );
589
+ }
590
+
591
+ async function materializeFlowCredential(
592
+ execution: SelfTestExecutionContext,
593
+ inputs: Readonly<Record<string, string>>,
594
+ options: {
595
+ isAbandoned?: () => boolean;
596
+ connectionId?: string;
597
+ externalRef?: string;
598
+ } = {},
599
+ ): Promise<
600
+ | { credential: Record<string, string> }
601
+ | Exclude<SelfTestConnectionResolution, { kind: "connection" }>
602
+ > {
603
+ const authFlow = execution.authFlow;
604
+ if (!authFlow) {
605
+ return {
606
+ kind: "flow_error",
607
+ code: "auth_flow_unavailable",
608
+ message:
609
+ "Provider declares a credentials auth flow but the self-test host has no auth-flow driver.",
610
+ };
611
+ }
612
+ const flowId = `self-test-${randomUUID()}`;
613
+ // The login must ride the SAME proxy/connection affinity the probe will
614
+ // use (createAuthFlowContext keys affinity on connectionId) — otherwise
615
+ // IP/session-bound upstreams see the cookie arrive from a different
616
+ // session and reject it.
617
+ const started = parseAuthFlowResponse(
618
+ await authFlow({
619
+ route: "start",
620
+ requestId: `${execution.requestId}-auth-start-${randomUUID()}`,
621
+ flowId,
622
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
623
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
624
+ }),
625
+ );
626
+ if (!started.ok) {
627
+ return { kind: "flow_error", code: started.code, message: started.message };
628
+ }
629
+ let turn = started.turn;
630
+ const flowContext = applyAuthFlowContextPatch({}, started.contextPatch);
631
+ if (turn.kind === "abort") {
632
+ // Terminal turn: continuing after an abort would replay credentials into
633
+ // a flow that already refused to proceed. Not memoized (flow errors are
634
+ // never cached) — an abort can be transient upstream maintenance.
635
+ return {
636
+ kind: "flow_error",
637
+ code: "auth_flow_aborted",
638
+ message: "Auth flow aborted before requesting input.",
639
+ };
640
+ }
641
+ if (turn.kind !== "complete") {
642
+ // Validate the start turn BEFORE submitting credentials: an unknown
643
+ // kind may be a provider typo or a stage that must not receive the
644
+ // probe inputs. `retry` counts as an input prompt at this stage.
645
+ if (turn.kind !== "retry" && !INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
646
+ return {
647
+ kind: "flow_error",
648
+ code: "auth_flow_unexpected_turn",
649
+ message: `Auth flow start returned an unrecognized turn kind "${turn.kind}".`,
650
+ };
651
+ }
652
+ // Auto-continue ONLY into input prompts (form/retry). Other known
653
+ // interactive stages (redirect, poll, pending, challenge, message,
654
+ // multi_choice) are valid flows that are NOT asking for the credential
655
+ // inputs — posting the password there submits it to the wrong stage.
656
+ // They are a genuine headless gap: the memoized multi-turn skip.
657
+ if (turn.kind !== "form" && turn.kind !== "retry") {
658
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
659
+ }
660
+ // Submit ONLY what the turn asks for: a first stage of a multi-step
661
+ // login may request a subset (or different fields entirely) — posting
662
+ // the full inputs would send secrets to the wrong stage. Only the
663
+ // schema's REQUIRED fields are mandatory (defineCredentialsAuth
664
+ // encodes optional fields by omitting them from `required`); a turn
665
+ // whose required fields we do not hold is a headless gap (multi-turn).
666
+ // A turn with no declared schema keeps full-input semantics.
667
+ const requestedFields = turnRequestedFields(turn);
668
+ let submitInputs: Record<string, string> = { ...inputs };
669
+ if (requestedFields !== null) {
670
+ if (requestedFields.required.some((field) => inputs[field] === undefined)) {
671
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
672
+ }
673
+ submitInputs = Object.fromEntries(
674
+ requestedFields.properties
675
+ .filter((field) => inputs[field] !== undefined)
676
+ .map((field) => [field, inputs[field] as string]),
677
+ );
678
+ }
679
+ // The case deadline may have fired while start() was still running.
680
+ // Never submit real credentials into a flow whose case already
681
+ // reported self_test_timeout — a late continue is a real upstream
682
+ // login/OTP attempt nobody is waiting for.
683
+ if (options.isAbandoned?.() === true) {
684
+ return {
685
+ kind: "flow_error",
686
+ code: "self_test_timeout",
687
+ message: "Case deadline passed before credential submission; flow abandoned.",
688
+ };
689
+ }
690
+ const continued = parseAuthFlowResponse(
691
+ await authFlow({
692
+ route: "continue",
693
+ requestId: `${execution.requestId}-auth-continue-${randomUUID()}`,
694
+ flowId,
695
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
696
+ ...(options.externalRef ? { externalRef: options.externalRef } : {}),
697
+ input: submitInputs,
698
+ ...(Object.keys(flowContext).length > 0 ? { context: flowContext } : {}),
699
+ }),
700
+ );
701
+ if (!continued.ok) {
702
+ // Providers built with defineCredentialsAuth cannot return a retry
703
+ // turn — a rejected password THROWS and /auth/continue answers with
704
+ // an auth-shaped 401/403. That is a credential REJECTION (memoized,
705
+ // so the probe never hammers a locked-out login); every other
706
+ // status stays an uncached transient retry.
707
+ if (AUTH_REJECTION_HTTP_STATUSES.has(continued.httpStatus)) {
708
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
709
+ }
710
+ return { kind: "flow_error", code: continued.code, message: continued.message };
711
+ }
712
+ turn = continued.turn;
713
+ }
714
+ if (turn.kind === "abort") {
715
+ return {
716
+ kind: "flow_error",
717
+ code: "auth_flow_aborted",
718
+ message: "Auth flow aborted after credential submission.",
719
+ };
720
+ }
721
+ if (turn.kind === "retry") {
722
+ // A retry turn AFTER submission is a credential rejection, not an
723
+ // interactive gap — surfaced distinctly so monitoring can treat it as
724
+ // a real outage, and memoized by the caller (lockout safety).
725
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON };
726
+ }
727
+ if (turn.kind !== "complete") {
728
+ // Only KNOWN interactive kinds are a genuine "cannot complete headless"
729
+ // multi-turn gap (memoized by the caller). An unknown kind is ambiguous
730
+ // — it may encode a transient provider failure — so it reports as a
731
+ // flow error, which is never memoized, instead of freezing the signal.
732
+ if (!INTERACTIVE_TURN_KIND_SET.has(turn.kind)) {
733
+ return {
734
+ kind: "flow_error",
735
+ code: "auth_flow_unexpected_turn",
736
+ message: `Auth flow returned an unrecognized turn kind "${turn.kind}".`,
737
+ };
738
+ }
739
+ return { kind: "skip", skipReason: SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON };
740
+ }
741
+ const credential = completedCredentialFromTurn(turn.data);
742
+ if (!credential) {
743
+ return {
744
+ kind: "flow_error",
745
+ code: "auth_flow_invalid_credential",
746
+ message: "Auth flow completed without a string-valued credential payload.",
747
+ };
748
+ }
749
+ // Redaction contract: flow-issued secrets are registered BEFORE any probe
750
+ // output can be built from them.
751
+ registerSensitiveValues(execution, Object.values(credential));
752
+ return { credential };
753
+ }
754
+
755
+ async function resolveSelfTestConnection(
279
756
  execution: SelfTestExecutionContext,
280
757
  operationId: string,
281
758
  suite: AnyHealthCheckSuite,
282
- ): { connection?: OperationConnection } | { skipReason: string } {
283
- if (!suite.requiresConnection) return {};
759
+ options: { forceLogin?: boolean; isAbandoned?: () => boolean } = {},
760
+ ): Promise<SelfTestConnectionResolution> {
761
+ if (!suite.requiresConnection) return { kind: "connection" };
284
762
  const inputs = execution.credentials ?? {};
285
763
  const declaredFields = Object.keys(
286
764
  (execution.provider.healthProbe ?? execution.provider.healthMonitor)?.credentialInputs ?? {},
287
765
  );
288
766
  for (const field of declaredFields) {
289
767
  if (!inputs[field]) {
290
- return { skipReason: `credential_missing:${field}` };
768
+ return { kind: "skip", skipReason: `credential_missing:${field}` };
291
769
  }
292
770
  }
293
771
  if (declaredFields.length === 0 && Object.keys(inputs).length === 0) {
294
- return { skipReason: "credential_missing:credentials" };
772
+ return { kind: "skip", skipReason: "credential_missing:credentials" };
773
+ }
774
+
775
+ // The connection id seeds proxy/connection affinity in the provider
776
+ // context, so it must be STABLE per (provider, credentialInputs): a cached
777
+ // session replayed under a per-request id would ride a different proxy/IP
778
+ // each cycle and upstreams would treat the cookie as stale or suspicious.
779
+ // The id carries only a hash of the inputs, never the inputs themselves.
780
+ //
781
+ // Providers declaring `proxy.session.affinity: "operation"` pin the PROBE's
782
+ // proxy to `${providerId}/${operationId}` regardless of connection id — so
783
+ // the login must ride that exact key, and the session cache splits per
784
+ // operation (one shared cookie would otherwise hop between per-operation
785
+ // proxies).
786
+ const operationAffinity =
787
+ typeof execution.provider.proxy === "object" &&
788
+ execution.provider.proxy?.session?.affinity === "operation";
789
+ const credentialKey = credentialSessionCacheKey(execution.provider.id, inputs);
790
+ const affinityKey = operationAffinity ? `${credentialKey}:${operationId}` : credentialKey;
791
+ // ONE id for the auth flow AND the probe connection: providers may bind
792
+ // the issued credential to FlowContext.connectionId and later compare it
793
+ // against ctx.request.connectionId. Operation-affinity providers use the
794
+ // probe's exact proxy key (providerId/operationId); everyone else uses the
795
+ // stable per-credential hash.
796
+ const connectionId = operationAffinity
797
+ ? `${execution.provider.id}/${operationId}`
798
+ : `self-test-${createHash("sha256").update(affinityKey).digest("hex").slice(0, 22)}`;
799
+ const buildConnection = (secrets: Readonly<Record<string, string>>): OperationConnection => ({
800
+ id: connectionId,
801
+ mode: "credentials",
802
+ secrets: { ...secrets },
803
+ metadata: { purpose: "provider-self-test", operationId },
804
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
805
+ });
806
+
807
+ const auth = execution.provider.auth;
808
+ if (auth?.mode !== "credentials" || !auth.flow) {
809
+ // Providers without a declared credentials flow keep raw-input semantics
810
+ // — and the pre-existing per-request connection id: there is no session
811
+ // to keep on one affinity, and a stable id would silently pin every
812
+ // cycle of a connection-affinity proxy to the same upstream session.
813
+ return {
814
+ kind: "connection",
815
+ connection: {
816
+ ...buildConnection(inputs),
817
+ id: `self-test-${execution.requestId}`,
818
+ },
819
+ credentialSource: "inputs",
820
+ };
821
+ }
822
+
823
+ const cacheKey = affinityKey;
824
+ const cached = execution.sessionCache.get(cacheKey);
825
+ // DR-7 upstream-account safety: a memoized multi-turn outcome
826
+ // short-circuits to the auth_flow_multi_turn skip WITHOUT re-driving
827
+ // flow.start()/flow.continue() — every re-drive is a real upstream login
828
+ // submission (OTP sends, lockout risk), and the probe scheduler would
829
+ // otherwise repeat it every cycle forever. Changed credentialInputs hash
830
+ // to a different key and re-attempt immediately; otherwise the entry
831
+ // expires after a generous TTL (or process restart) so a provider whose
832
+ // flow becomes single-turn again is eventually re-probed.
833
+ if (cached?.kind === "multi_turn" || cached?.kind === "rejected") {
834
+ if (Date.now() - cached.cachedAtMs < SELF_TEST_MULTI_TURN_RETRY_AFTER_MS) {
835
+ return {
836
+ kind: "skip",
837
+ skipReason:
838
+ cached.kind === "rejected"
839
+ ? SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON
840
+ : SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON,
841
+ };
842
+ }
843
+ execution.sessionCache.delete(cacheKey);
844
+ }
845
+ if (options.forceLogin !== true && cached?.kind === "credential") {
846
+ if (Date.now() - cached.cachedAtMs >= SELF_TEST_CREDENTIAL_MAX_AGE_MS) {
847
+ // Age-bounded: expiry modes that never 401 (login-page 200s,
848
+ // assertion failures) must not replay one stale session forever.
849
+ execution.sessionCache.delete(cacheKey);
850
+ } else {
851
+ registerSensitiveValues(execution, Object.values(cached.credential));
852
+ return {
853
+ kind: "connection",
854
+ connection: buildConnection(cached.credential),
855
+ credentialSource: "cache",
856
+ cacheKey,
857
+ };
858
+ }
859
+ }
860
+ const materialized = await materializeFlowCredential(execution, inputs, {
861
+ ...(options.isAbandoned !== undefined ? { isAbandoned: options.isAbandoned } : {}),
862
+ connectionId,
863
+ externalRef: `${execution.provider.id}-${operationId}-self-test`,
864
+ });
865
+ if (!("credential" in materialized)) {
866
+ // Only the multi-turn SKIP is negative-cached. Flow ERRORS
867
+ // (auth_flow_unavailable / auth_flow_failed / invalid credential
868
+ // payloads, or a thrown start/continue) are never memoized: they are
869
+ // typically transient upstream or host failures, so each cycle may
870
+ // retry — permanently caching an error would silently freeze the
871
+ // signal on a blip, while retrying a FAILED request is not a repeated
872
+ // successful login submission.
873
+ if (materialized.kind === "skip" && options.isAbandoned?.() !== true) {
874
+ if (materialized.skipReason === SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON) {
875
+ execution.sessionCache.set(cacheKey, { kind: "multi_turn", cachedAtMs: Date.now() });
876
+ } else if (materialized.skipReason === SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON) {
877
+ execution.sessionCache.set(cacheKey, { kind: "rejected", cachedAtMs: Date.now() });
878
+ }
879
+ }
880
+ return materialized;
295
881
  }
882
+ // A flow that outlived the case deadline still completes here (the timeout
883
+ // only races the promise, it cannot cancel it). The case already reported
884
+ // self_test_timeout — caching this credential would let the next probe
885
+ // reuse a login whose latency just failed the case, hiding the failure.
886
+ if (options.isAbandoned?.() === true) {
887
+ return {
888
+ kind: "flow_error",
889
+ code: "self_test_timeout",
890
+ message: "Auth flow completed after the case deadline; credential discarded.",
891
+ };
892
+ }
893
+ execution.sessionCache.set(cacheKey, {
894
+ kind: "credential",
895
+ credential: materialized.credential,
896
+ cachedAtMs: Date.now(),
897
+ });
296
898
  return {
297
- connection: {
298
- id: `self-test-${execution.requestId}`,
299
- mode: "credentials",
300
- secrets: { ...inputs },
301
- metadata: { purpose: "provider-self-test", operationId },
302
- externalRef: `${execution.provider.id}-${operationId}-self-test`,
303
- },
899
+ kind: "connection",
900
+ connection: buildConnection(materialized.credential),
901
+ credentialSource: "flow",
902
+ cacheKey,
304
903
  };
305
904
  }
306
905
 
906
+ /** A failed probe whose HTTP status is auth-shaped invalidates a cached session once. */
907
+ function isAuthFailureCaseResult(result: SelfTestCaseResult): boolean {
908
+ return result.status === "failed" && (result.httpStatus === 401 || result.httpStatus === 403);
909
+ }
910
+
307
911
  async function executeSelfTestCase(
308
912
  execution: SelfTestExecutionContext,
309
913
  operationId: string,
310
914
  suite: AnyHealthCheckSuite,
311
915
  healthCase: AnyHealthCheckCase,
312
916
  ): Promise<SelfTestCaseResult> {
313
- const { provider, invoke, sensitiveValues } = execution;
314
- const redact = (text: string) => redactSelfTestText(text, sensitiveValues);
315
- const startedAt = new Date().toISOString();
316
- const startedAtMs = performance.now();
317
- const finish = (
318
- partial: Omit<
319
- SelfTestCaseResult,
320
- "operationId" | "caseName" | "startedAt" | "finishedAt" | "responseTimeMs"
321
- > & { responseTimeMs?: number },
322
- ): SelfTestCaseResult => ({
323
- operationId,
324
- caseName: healthCase.name,
325
- startedAt,
326
- finishedAt: new Date().toISOString(),
327
- responseTimeMs:
328
- partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
329
- ...partial,
330
- });
917
+ const { provider, invoke } = execution;
918
+ // execution.sensitiveValues may grow while the case runs (flow-issued
919
+ // secrets); redact always reads the live array.
920
+ const redact = (text: string) => redactSelfTestText(text, execution.sensitiveValues);
331
921
  const defaultLabel = redact(healthCase.description ?? healthCase.name);
922
+ const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
923
+ // One deadline for the WHOLE case: connection materialization (auth flow),
924
+ // the probe, and the one-shot auth retry all draw from the same budget —
925
+ // a 30s case must never take ~4×30s across its stages.
926
+ const caseDeadlineAtMs = performance.now() + timeoutMs;
927
+ const remainingCaseTimeoutMs = () =>
928
+ Math.max(1, Math.ceil(caseDeadlineAtMs - performance.now()));
929
+
930
+ const beginCase = () => {
931
+ const startedAt = new Date().toISOString();
932
+ const startedAtMs = performance.now();
933
+ return {
934
+ startedAtMs,
935
+ finish: (
936
+ partial: Omit<
937
+ SelfTestCaseResult,
938
+ "operationId" | "caseName" | "startedAt" | "finishedAt" | "responseTimeMs"
939
+ > & { responseTimeMs?: number },
940
+ ): SelfTestCaseResult => ({
941
+ operationId,
942
+ caseName: healthCase.name,
943
+ startedAt,
944
+ finishedAt: new Date().toISOString(),
945
+ responseTimeMs:
946
+ partial.responseTimeMs ?? Math.max(0, Math.round(performance.now() - startedAtMs)),
947
+ ...partial,
948
+ }),
949
+ };
950
+ };
951
+ const caseScope = beginCase();
332
952
 
333
953
  if (healthCase.enabled && healthCase.enabled() === false) {
334
- return finish({
954
+ return caseScope.finish({
335
955
  status: "skipped",
336
956
  label: defaultLabel,
337
957
  skipReason: "disabled",
338
958
  });
339
959
  }
340
960
 
341
- const connectionResolution = buildSelfTestConnection(execution, operationId, suite);
342
- if ("skipReason" in connectionResolution) {
343
- return finish({
344
- status: "skipped",
961
+ const resolveConnection = async (forceLogin: boolean): Promise<SelfTestConnectionResolution> => {
962
+ // The timeout only races the flow promise — it cannot cancel it. Once
963
+ // the deadline fires, the still-running resolution is marked abandoned
964
+ // so its late completion cannot write the session cache.
965
+ let abandoned = false;
966
+ try {
967
+ return await withCaseTimeout(
968
+ () =>
969
+ resolveSelfTestConnection(execution, operationId, suite, {
970
+ forceLogin,
971
+ isAbandoned: () => abandoned,
972
+ }),
973
+ remainingCaseTimeoutMs(),
974
+ );
975
+ } catch (error) {
976
+ abandoned = true;
977
+ return {
978
+ kind: "flow_error",
979
+ code: error instanceof SelfTestCaseTimeoutError ? "self_test_timeout" : "auth_flow_failed",
980
+ message: error instanceof Error ? error.message : String(error),
981
+ };
982
+ }
983
+ };
984
+
985
+ const nonConnectionResult = (
986
+ resolution: Exclude<SelfTestConnectionResolution, { kind: "connection" }>,
987
+ ): SelfTestCaseResult => {
988
+ if (resolution.kind === "skip") {
989
+ return caseScope.finish({
990
+ status: "skipped",
991
+ label: defaultLabel,
992
+ skipReason: resolution.skipReason,
993
+ });
994
+ }
995
+ return caseScope.finish({
996
+ status: "error",
345
997
  label: defaultLabel,
346
- skipReason: connectionResolution.skipReason,
998
+ error: { code: resolution.code, message: redact(resolution.message) },
347
999
  });
348
- }
349
- const connection = connectionResolution.connection;
1000
+ };
350
1001
 
351
- const timeoutMs = resolveCaseTimeoutMs(execution, suite, healthCase);
352
- try {
353
- return await withCaseTimeout(async () => {
354
- const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
355
- const preparedInput = healthCase.prepareInput
356
- ? await healthCase.prepareInput({
357
- providerId: provider.id,
358
- operationId,
359
- input: resolvedInput,
360
- ...(connection ? { connectionId: connection.id } : {}),
361
- gateway: {
362
- execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
363
- if (foreignProviderId !== provider.id) {
364
- throw new Error(
365
- `Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`,
366
- );
367
- }
368
- const startedGatewayMs = performance.now();
369
- const executed = await invoke({
370
- operationId: gatewayOperationId,
371
- input: gatewayInput,
372
- connection,
373
- requestId: `${execution.requestId}-prepare-${randomUUID()}`,
374
- });
375
- return {
376
- status: executed.status,
377
- duration: performance.now() - startedGatewayMs,
378
- data: executed.data,
379
- meta: executed.meta,
380
- };
1002
+ const runProbeAttempt = async (
1003
+ connection: OperationConnection | undefined,
1004
+ ): Promise<SelfTestCaseResult> => {
1005
+ // gateway.execute keeps its contract — EVERY helper status is returned
1006
+ // to the prepareInput hook (it may branch on 401 itself). The last
1007
+ // auth-shaped helper status is only RECORDED: if the hook then throws,
1008
+ // the case fails WITH that status so stale-session recovery triggers.
1009
+ let prepareAuthStatus: number | null = null;
1010
+ // Share the OUTER case scope: startedAt/responseTimeMs must cover the
1011
+ // WHOLE case auth-flow materialization included — not just the final
1012
+ // operation attempt, or a slow login reads as a fast healthy case.
1013
+ const { startedAtMs, finish } = caseScope;
1014
+ try {
1015
+ return await withCaseTimeout(async () => {
1016
+ const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
1017
+ const preparedInput = healthCase.prepareInput
1018
+ ? await healthCase.prepareInput({
1019
+ providerId: provider.id,
1020
+ operationId,
1021
+ input: resolvedInput,
1022
+ ...(connection ? { connectionId: connection.id } : {}),
1023
+ gateway: {
1024
+ execute: async (foreignProviderId, gatewayOperationId, gatewayInput) => {
1025
+ if (foreignProviderId !== provider.id) {
1026
+ throw new Error(
1027
+ `Self-test prepareInput may only invoke provider "${provider.id}" operations (requested "${foreignProviderId}").`,
1028
+ );
1029
+ }
1030
+ const startedGatewayMs = performance.now();
1031
+ const executed = await invoke({
1032
+ operationId: gatewayOperationId,
1033
+ input: gatewayInput,
1034
+ connection,
1035
+ requestId: `${execution.requestId}-prepare-${randomUUID()}`,
1036
+ });
1037
+ if (executed.status === 401 || executed.status === 403) {
1038
+ prepareAuthStatus = executed.status;
1039
+ }
1040
+ return {
1041
+ status: executed.status,
1042
+ duration: performance.now() - startedGatewayMs,
1043
+ data: executed.data,
1044
+ meta: executed.meta,
1045
+ };
1046
+ },
381
1047
  },
382
- },
383
- })
384
- : resolvedInput;
1048
+ })
1049
+ : resolvedInput;
385
1050
 
386
- const executed = await invoke({
387
- operationId,
388
- input: preparedInput,
389
- connection,
390
- requestId: `${execution.requestId}-${randomUUID()}`,
391
- });
392
- const durationMs = performance.now() - startedAtMs;
1051
+ const executed = await invoke({
1052
+ operationId,
1053
+ input: preparedInput,
1054
+ connection,
1055
+ requestId: `${execution.requestId}-${randomUUID()}`,
1056
+ });
1057
+ const durationMs = performance.now() - startedAtMs;
1058
+
1059
+ if (executed.status < 200 || executed.status >= 300) {
1060
+ return finish({
1061
+ status: "failed",
1062
+ label: defaultLabel,
1063
+ httpStatus: executed.status,
1064
+ error: {
1065
+ code: upstreamErrorCode(executed.data) ?? "operation_failed",
1066
+ message: redact(
1067
+ upstreamErrorMessage(executed.data) ??
1068
+ `Operation invocation failed with status ${executed.status}`,
1069
+ ),
1070
+ },
1071
+ });
1072
+ }
393
1073
 
394
- if (executed.status < 200 || executed.status >= 300) {
1074
+ const assertionContext: HealthCheckAssertionContext = {
1075
+ status: executed.status,
1076
+ data: executed.data,
1077
+ durationMs,
1078
+ ...(executed.meta ? { meta: executed.meta } : {}),
1079
+ };
1080
+ let assertionResult: unknown;
1081
+ try {
1082
+ assertionResult = await healthCase.assertions(assertionContext);
1083
+ } catch (assertionError) {
1084
+ return finish({
1085
+ status: "failed",
1086
+ label: defaultLabel,
1087
+ httpStatus: executed.status,
1088
+ assertion: {
1089
+ passed: false,
1090
+ message: redact(
1091
+ assertionError instanceof Error ? assertionError.message : String(assertionError),
1092
+ ),
1093
+ },
1094
+ });
1095
+ }
1096
+ const statusValue = objectProperty(assertionResult, "status");
1097
+ const overrideStatus =
1098
+ statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
1099
+ const labelValue = objectProperty(assertionResult, "label");
1100
+ const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
395
1101
  return finish({
396
- status: "failed",
397
- label: defaultLabel,
1102
+ status: overrideStatus ?? "ok",
1103
+ label: overrideLabel ?? defaultLabel,
398
1104
  httpStatus: executed.status,
399
- error: {
400
- code: upstreamErrorCode(executed.data) ?? "operation_failed",
401
- message: redact(
402
- upstreamErrorMessage(executed.data) ??
403
- `Operation invocation failed with status ${executed.status}`,
404
- ),
405
- },
1105
+ assertion: { passed: true },
1106
+ });
1107
+ }, remainingCaseTimeoutMs());
1108
+ } catch (error) {
1109
+ if (error instanceof SelfTestCaseTimeoutError) {
1110
+ return finish({
1111
+ status: "error",
1112
+ label: defaultLabel,
1113
+ error: { code: "self_test_timeout", message: redact(error.message) },
406
1114
  });
407
1115
  }
408
-
409
- const assertionContext: HealthCheckAssertionContext = {
410
- status: executed.status,
411
- data: executed.data,
412
- durationMs,
413
- ...(executed.meta ? { meta: executed.meta } : {}),
414
- };
415
- let assertionResult: unknown;
416
- try {
417
- assertionResult = await healthCase.assertions(assertionContext);
418
- } catch (assertionError) {
1116
+ if (prepareAuthStatus !== null) {
419
1117
  return finish({
420
1118
  status: "failed",
421
1119
  label: defaultLabel,
422
- httpStatus: executed.status,
1120
+ httpStatus: prepareAuthStatus,
423
1121
  assertion: {
424
1122
  passed: false,
425
- message: redact(
426
- assertionError instanceof Error ? assertionError.message : String(assertionError),
427
- ),
1123
+ message: redact(error instanceof Error ? error.message : String(error)),
428
1124
  },
429
1125
  });
430
1126
  }
431
- const statusValue = objectProperty(assertionResult, "status");
432
- const overrideStatus =
433
- statusValue === "ok" || statusValue === "degraded" ? statusValue : undefined;
434
- const labelValue = objectProperty(assertionResult, "label");
435
- const overrideLabel = typeof labelValue === "string" ? redact(labelValue) : undefined;
436
- return finish({
437
- status: overrideStatus ?? "ok",
438
- label: overrideLabel ?? defaultLabel,
439
- httpStatus: executed.status,
440
- assertion: { passed: true },
441
- });
442
- }, timeoutMs);
443
- } catch (error) {
444
- if (error instanceof SelfTestCaseTimeoutError) {
445
1127
  return finish({
446
1128
  status: "error",
447
1129
  label: defaultLabel,
448
- error: { code: "self_test_timeout", message: redact(error.message) },
1130
+ error: {
1131
+ code: "self_test_execution_error",
1132
+ message: redact(error instanceof Error ? error.message : String(error)),
1133
+ },
449
1134
  });
450
1135
  }
451
- return finish({
452
- status: "error",
453
- label: defaultLabel,
454
- error: {
455
- code: "self_test_execution_error",
456
- message: redact(error instanceof Error ? error.message : String(error)),
457
- },
458
- });
1136
+ };
1137
+
1138
+ const resolution = await resolveConnection(false);
1139
+ if (resolution.kind !== "connection") {
1140
+ return nonConnectionResult(resolution);
459
1141
  }
1142
+
1143
+ let result = await runProbeAttempt(resolution.connection);
1144
+
1145
+ // One-shot session recovery: a cached credential that fails the probe with
1146
+ // an auth-shaped status is invalidated, the flow re-runs ONCE, and the
1147
+ // probe retries once. Fresh (just-materialized) credentials never retry.
1148
+ if (
1149
+ resolution.credentialSource === "cache" &&
1150
+ resolution.cacheKey !== undefined &&
1151
+ isAuthFailureCaseResult(result)
1152
+ ) {
1153
+ execution.sessionCache.delete(resolution.cacheKey);
1154
+ const retryResolution = await resolveConnection(true);
1155
+ if (retryResolution.kind !== "connection") {
1156
+ return nonConnectionResult(retryResolution);
1157
+ }
1158
+ result = await runProbeAttempt(retryResolution.connection);
1159
+ // The retry's fresh credential is subject to the same eviction rule
1160
+ // as a first-attempt fresh credential (below).
1161
+ if (retryResolution.cacheKey !== undefined && isAuthFailureCaseResult(result)) {
1162
+ execution.sessionCache.delete(retryResolution.cacheKey);
1163
+ }
1164
+ return result;
1165
+ }
1166
+
1167
+ // A FRESH credential the probe just rejected is known-bad: evict it so the
1168
+ // next cycle logs in anew instead of replaying a guaranteed-stale session
1169
+ // once before recovering. (No retry here — fresh credentials never retry.)
1170
+ if (
1171
+ resolution.credentialSource === "flow" &&
1172
+ resolution.cacheKey !== undefined &&
1173
+ isAuthFailureCaseResult(result)
1174
+ ) {
1175
+ execution.sessionCache.delete(resolution.cacheKey);
1176
+ }
1177
+
1178
+ return result;
460
1179
  }
461
1180
 
462
1181
  interface SelectedCase {
@@ -557,6 +1276,10 @@ export function createSelfTestApp(provider: ProviderDefinition, options: SelfTes
557
1276
  const app = new Hono();
558
1277
  const planDigest = computeSelfTestPlanDigest(provider);
559
1278
  const requestBudgetMs = resolveRequestBudgetMs(options);
1279
+ // In-process flow-credential session cache (providerId + credentialInputs
1280
+ // hash → materialized credential). Lives as long as the app so consecutive
1281
+ // probe cycles never log in to the upstream more than once per session.
1282
+ const sessionCache: SelfTestCredentialSessionCache = new Map();
560
1283
  let busy = false;
561
1284
 
562
1285
  app.notFound((c) => c.json({ error: { code: "not_found", message: "Not found" } }, 404));
@@ -648,6 +1371,7 @@ export function createSelfTestApp(provider: ProviderDefinition, options: SelfTes
648
1371
  const execution: SelfTestExecutionContext = {
649
1372
  provider,
650
1373
  invoke: options.invoke,
1374
+ ...(options.authFlow ? { authFlow: options.authFlow } : {}),
651
1375
  requestId: request.requestId,
652
1376
  credentials: request.credentials?.inputs,
653
1377
  requestTimeoutMs: request.timeoutMs,
@@ -655,6 +1379,7 @@ export function createSelfTestApp(provider: ProviderDefinition, options: SelfTes
655
1379
  env: options.env,
656
1380
  credentialInputs: request.credentials?.inputs,
657
1381
  }),
1382
+ sessionCache,
658
1383
  };
659
1384
  const deadline = performance.now() + requestBudgetMs;
660
1385
  const results: SelfTestCaseResult[] = [];