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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AUTHORING.md CHANGED
@@ -32,6 +32,45 @@ No retry templates, next-action routing, or other agent choreography: provider p
32
32
 
33
33
  Declare the union in the operation `output` schema (`z.union([CreatedSchema, NeedsInputSchema])`). Reserve hard errors for genuinely unrecoverable flows (payment-gated, unsupported input kinds) and state the concrete reason in the error `message` itself, not only in `details`. Reference implementation: `providers/catchtable` `reserve` in the platform monorepo.
34
34
 
35
+ ### Attempt tokens: the server carries the decisions
36
+
37
+ When a mutation needs more than one user decision (or one decision plus a
38
+ final go/no-go), do not make the agent re-send accumulated state across
39
+ rounds — weak models drop or corrupt it. Split the operation into a
40
+ **prepare/confirm pair** driven by a server-held attempt record:
41
+
42
+ - The prepare operation is non-destructive. A start call takes only the
43
+ scalar intent fields; every response returns a fresh `attempt_token`
44
+ referencing a server-side record (`ctx.choice.issue` with
45
+ `storage.mode: "server"`) that stores every settled decision. Continue
46
+ calls take `attempt_token` plus only the NEW answers.
47
+ - `needs_input` rounds list only the still-pending selections; settled
48
+ decisions may ride along in a display-only field but are never re-sent.
49
+ - When nothing is pending, the prepare operation returns `status: "ready"`
50
+ with a human-readable summary — the consumer's user-facing confirmation.
51
+ - The confirm operation is the only mutation and takes exactly
52
+ `{attempt_token}`. It re-validates everything live before executing and
53
+ returns `needs_input` (fresh token) instead of proceeding when upstream
54
+ drift invalidates a stored decision — never substitute a different option
55
+ for what the user picked.
56
+ - Expired or foreign tokens fail factually (nothing happened; start a new
57
+ attempt with the scalar fields) — no answer salvage from a dead token.
58
+ - **The provider must enforce consumption itself.** `ctx.choice` server
59
+ storage keeps tokens parseable until TTL — `parse` does not invalidate
60
+ them, so a confirm handler that only parses can be replayed into a second
61
+ booking or payment. After a successful execution, record the result under
62
+ the token's digest in `ctx.state` and make replays idempotent: a repeated
63
+ confirm returns the original created payload without touching upstream,
64
+ and later prepare rounds on the consumed token fail factually with the
65
+ existing reference. Record the result only after upstream success, so an
66
+ interrupted confirm stays retryable.
67
+
68
+ The invariant behind all of it: complex flow state is the system's job, not
69
+ the model's. The model carries exactly one opaque key between calls.
70
+ Reference implementations: `providers/catchtable` `reserve`/`reserve-confirm`
71
+ (including the consume-on-success guard) and `providers/modu-parking`
72
+ payment state tokens in the platform monorepo.
73
+
35
74
  ### Description template
36
75
 
37
76
  Every operation `description` MUST be at least 150 characters and follow this structure:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.5
4
+
5
+ - Release candidate for main commit 82fa14e99a9af7edd44e3196aa3f4e87b4699edf.
6
+
3
7
  ## 2.2.0-beta.4
4
8
 
5
9
  - Release candidate for main commit 73f2c6ec429c2fbce8ac458a67111e4844b99178.
@@ -3546,9 +3546,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
3546
3546
  ];
3547
3547
  }
3548
3548
 
3549
+ // Splits secret findings into still-active findings and acknowledged
3550
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
3551
+ // (same pragma placement: the finding line or the line directly above it).
3552
+ // Every finding source carries a line number (entropy candidates and located
3553
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
3554
+ // defensively.
3555
+ function partitionSecretScanAllowOverrides(
3556
+ providerRoot: string,
3557
+ findings: readonly SecretFinding[],
3558
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
3559
+ const fileLineCache = new Map<string, string[]>();
3560
+ const active: SecretFinding[] = [];
3561
+ const overridden: SecretFinding[] = [];
3562
+
3563
+ for (const finding of findings) {
3564
+ if (finding.line === undefined) {
3565
+ active.push(finding);
3566
+ continue;
3567
+ }
3568
+ const absolute = resolve(providerRoot, finding.file);
3569
+ let lines = fileLineCache.get(absolute);
3570
+ if (lines === undefined) {
3571
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
3572
+ fileLineCache.set(absolute, lines);
3573
+ }
3574
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
3575
+ overridden.push(finding);
3576
+ } else {
3577
+ active.push(finding);
3578
+ }
3579
+ }
3580
+
3581
+ return { active, overridden };
3582
+ }
3583
+
3549
3584
  function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3550
- const findings = findSecretFindings(providerRoot, provider?.id);
3551
- const blockerFindings = findings.filter((finding) => finding.level !== "warn");
3585
+ const { active, overridden } = partitionSecretScanAllowOverrides(
3586
+ providerRoot,
3587
+ findSecretFindings(providerRoot, provider?.id),
3588
+ );
3589
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3552
3590
  if (blockerFindings.length > 0) {
3553
3591
  return {
3554
3592
  id: "secret-scan",
@@ -3568,7 +3606,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3568
3606
  ),
3569
3607
  };
3570
3608
  }
3571
- if (findings.length > 0) {
3609
+ if (active.length > 0 || overridden.length > 0) {
3610
+ const messageBase =
3611
+ active.length > 0
3612
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
3613
+ : "Potential credential-like strings were found in shareable files.";
3614
+ const message =
3615
+ overridden.length > 0
3616
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
3617
+ : messageBase;
3572
3618
  return {
3573
3619
  id: "secret-scan",
3574
3620
  category: "security",
@@ -3576,11 +3622,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3576
3622
  status: "warn",
3577
3623
  points: 8,
3578
3624
  maxPoints: CATEGORY_MAX_POINTS.security,
3579
- message:
3580
- "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
3625
+ message,
3581
3626
  remediation:
3582
- 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public.',
3583
- evidence: findings.map(
3627
+ 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public with `// @apifuse-allow secret-scan: <reason>`.',
3628
+ evidence: [...active, ...overridden].map(
3584
3629
  (finding) =>
3585
3630
  finding.evidence ??
3586
3631
  `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
@@ -3611,8 +3656,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
3611
3656
  if (!existsSync(filePath)) continue;
3612
3657
  const content = readFileSync(filePath, "utf8");
3613
3658
  for (const [label, pattern] of SECRET_PATTERNS) {
3614
- if (pattern.test(content)) {
3615
- findings.push({ label, file: relativePath });
3659
+ // Locate every match to its line so pattern findings carry the line
3660
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
3661
+ // must behave uniformly across entropy findings and pattern findings.
3662
+ const globalPattern = new RegExp(
3663
+ pattern.source,
3664
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
3665
+ );
3666
+ const seenLines = new Set<number>();
3667
+ for (const match of content.matchAll(globalPattern)) {
3668
+ const line = offsetToLine(content, match.index);
3669
+ if (seenLines.has(line)) continue;
3670
+ seenLines.add(line);
3671
+ findings.push({ label, file: relativePath, line });
3616
3672
  }
3617
3673
  }
3618
3674
  }
@@ -3670,7 +3726,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
3670
3726
  continue;
3671
3727
  }
3672
3728
  if (char === quote) {
3673
- if (cursor - contentStart >= 20) {
3729
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3674
3730
  candidates.push(line.slice(contentStart, cursor));
3675
3731
  }
3676
3732
  index = cursor;
@@ -3695,6 +3751,25 @@ function classifyEntropyCandidate(input: {
3695
3751
  if (!charset) return undefined;
3696
3752
  const entropy = shannonEntropy(value);
3697
3753
  const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3754
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
3755
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
3756
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
3757
+ // blocker-flagged. They are never skipped — entropy classification always
3758
+ // runs — but when the secret-ish context comes solely from identifier-
3759
+ // constant-shaped literal text (the line with those literals stripped
3760
+ // carries no secret-ish identifier), the finding is capped at a
3761
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
3762
+ // siblings — not just the candidate — matters for lines holding several
3763
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
3764
+ // header names ("Authorization", "apiKey") stay visible as genuine
3765
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
3766
+ // still escalate to blockers via the identifier side, and
3767
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
3768
+ // warning.
3769
+ const selfContextOnlyConstant =
3770
+ secretishContext &&
3771
+ isScreamingSnakeConstantValue(value) &&
3772
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3698
3773
  const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3699
3774
  if (entropy < threshold) return undefined;
3700
3775
 
@@ -3705,16 +3780,163 @@ function classifyEntropyCandidate(input: {
3705
3780
  charset === "hex"
3706
3781
  ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3707
3782
  : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
3783
+ const contextNote = selfContextOnlyConstant
3784
+ ? "; identifier-like constant (downgraded to warning)"
3785
+ : secretishContext
3786
+ ? ""
3787
+ : "; may be a false positive";
3708
3788
  return {
3709
3789
  label,
3710
3790
  file: input.file,
3711
3791
  line: input.lineNumber,
3712
- level: secretishContext ? "blocker" : "warn",
3792
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3713
3793
  remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3714
- evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
3794
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3715
3795
  };
3716
3796
  }
3717
3797
 
3798
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
3799
+ // separated segments, each essentially pure alphabetic — letters optionally
3800
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
3801
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
3802
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
3803
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
3804
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
3805
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
3806
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
3807
+ // it only decides whether a finding whose secret-ish context comes solely
3808
+ // from the literal's own text is downgraded from blocker to warning, so it
3809
+ // deliberately stays strict: values that merely contain a secret-ish word but
3810
+ // are not word-like constants keep full blocker severity.
3811
+ function isScreamingSnakeConstantValue(value: string): boolean {
3812
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
3813
+ const segments = value.split("_");
3814
+ if (segments.length < 2) return false;
3815
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
3816
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
3817
+ return digitCount / value.length <= 0.15;
3818
+ }
3819
+
3820
+ type LineStringLiteral = {
3821
+ // Index of the opening quote.
3822
+ start: number;
3823
+ // Index just past the closing quote (line end when unterminated).
3824
+ end: number;
3825
+ content: string;
3826
+ closed: boolean;
3827
+ role: "key" | "value";
3828
+ // Nearest unclosed bracket enclosing the literal's start, if any.
3829
+ container?: { bracket: "[" | "(" | "{"; index: number };
3830
+ };
3831
+
3832
+ // Stable identity for the container a literal sits in ("top" when the
3833
+ // literal is not inside any bracket on the line).
3834
+ function literalContainerKey(literal: LineStringLiteral): string {
3835
+ return literal.container
3836
+ ? `${literal.container.bracket}${literal.container.index}`
3837
+ : "top";
3838
+ }
3839
+
3840
+ // Single-pass line tokenizer: extracts every string literal with its span and
3841
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
3842
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
3843
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
3844
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
3845
+ // elements, call arguments, and assignment right-hand sides, even when a
3846
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
3847
+ // extractStringLiteralCandidates.
3848
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
3849
+ const literals: LineStringLiteral[] = [];
3850
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
3851
+ let index = 0;
3852
+ while (index < line.length) {
3853
+ const char = line[index];
3854
+ if (char !== '"' && char !== "'" && char !== "`") {
3855
+ if (char === "[" || char === "(" || char === "{") {
3856
+ bracketStack.push({ bracket: char, index });
3857
+ } else if (char === "]" || char === ")" || char === "}") {
3858
+ bracketStack.pop();
3859
+ }
3860
+ index += 1;
3861
+ continue;
3862
+ }
3863
+ const quote = char;
3864
+ const start = index;
3865
+ const contentStart = index + 1;
3866
+ let cursor = contentStart;
3867
+ let closed = false;
3868
+ while (cursor < line.length) {
3869
+ const inner = line[cursor];
3870
+ if (inner === "\\") {
3871
+ cursor += 2;
3872
+ continue;
3873
+ }
3874
+ if (inner === quote) {
3875
+ closed = true;
3876
+ break;
3877
+ }
3878
+ cursor += 1;
3879
+ }
3880
+ const contentEnd = Math.min(cursor, line.length);
3881
+ const end = closed ? cursor + 1 : line.length;
3882
+ const before = line.slice(0, start).trimEnd();
3883
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
3884
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
3885
+ literals.push({
3886
+ start,
3887
+ end,
3888
+ content: line.slice(contentStart, contentEnd),
3889
+ closed,
3890
+ role: keyPreceded && keyFollowed ? "key" : "value",
3891
+ container: bracketStack[bracketStack.length - 1],
3892
+ });
3893
+ index = end;
3894
+ }
3895
+ return literals;
3896
+ }
3897
+
3898
+ // Builds the context text used to decide whether a candidate's secret-ish
3899
+ // context is genuine. The candidate's own literal is ALWAYS stripped
3900
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
3901
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
3902
+ // stripped only when they share the candidate's non-call container — the same
3903
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
3904
+ // level (ternary arms) — so a value list of error codes cannot poison its own
3905
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
3906
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
3907
+ // argument genuinely describes the second, so stripping it would erase real
3908
+ // auth/token context. KEY-role literals are never stripped.
3909
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
3910
+ const literals = tokenizeLineStringLiterals(line);
3911
+ const candidateContainers = new Set<string>();
3912
+ for (const literal of literals) {
3913
+ if (literal.content === candidate) {
3914
+ candidateContainers.add(literalContainerKey(literal));
3915
+ }
3916
+ }
3917
+ let result = "";
3918
+ let previousEnd = 0;
3919
+ for (const literal of literals) {
3920
+ result += line.slice(previousEnd, literal.start);
3921
+ const isSelf = literal.content === candidate;
3922
+ const isSameNonCallContainerSibling =
3923
+ literal.role === "value" &&
3924
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
3925
+ isScreamingSnakeConstantValue(literal.content) &&
3926
+ literal.container?.bracket !== "(" &&
3927
+ candidateContainers.has(literalContainerKey(literal));
3928
+ if (isSelf || isSameNonCallContainerSibling) {
3929
+ const quote = line[literal.start] ?? "";
3930
+ result += quote + (literal.closed ? quote : "");
3931
+ } else {
3932
+ result += line.slice(literal.start, literal.end);
3933
+ }
3934
+ previousEnd = literal.end;
3935
+ }
3936
+ result += line.slice(previousEnd);
3937
+ return result;
3938
+ }
3939
+
3718
3940
  function shouldConsiderEntropyValue(value: string): boolean {
3719
3941
  const lower = value.toLowerCase();
3720
3942
  if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
@@ -3730,7 +3952,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
3730
3952
  if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3731
3953
  return false;
3732
3954
  }
3733
- return value.length >= 20;
3955
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
3734
3956
  }
3735
3957
 
3736
3958
  function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
@@ -3764,6 +3986,11 @@ function guessSecretName(line: string): string {
3764
3986
 
3765
3987
  const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3766
3988
 
3989
+ // Minimum length for a string literal to be considered an entropy candidate.
3990
+ // Shared by candidate extraction, entropy screening, and the context strip so
3991
+ // the three stay coherent.
3992
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
3993
+
3767
3994
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3768
3995
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3769
3996
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -1,5 +1,5 @@
1
1
  export { createServerApp, type ServeOptions, serve } from "./serve.js";
2
- export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
2
+ export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
3
3
  export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
5
5
  export { DEFAULT_SELF_TEST_PORT, deriveSelfTestToken, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, resolveSelfTestMasterSecrets, type SelfTestMasterSecrets, verifySelfTestAuthorization, } from "./self-test-token.js";
@@ -1,5 +1,5 @@
1
1
  export { createServerApp, serve } from "./serve.js";
2
- export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
2
+ export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
3
3
  export { resolveHealthCheckInputDateTokens } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
5
5
  export { DEFAULT_SELF_TEST_PORT, deriveSelfTestToken, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_ENV, PROVIDER_RUNTIME_SELF_TEST_MASTER_SECRET_PREVIOUS_ENV, PROVIDER_RUNTIME_SELF_TEST_PORT_ENV, resolveSelfTestMasterSecrets, verifySelfTestAuthorization, } from "./self-test-token.js";
@@ -61,11 +61,53 @@ export type SelfTestOperationInvoke = (args: {
61
61
  data: unknown;
62
62
  meta?: Record<string, unknown>;
63
63
  }>;
64
+ export type SelfTestAuthFlowRoute = "start" | "continue";
65
+ /**
66
+ * In-process driver for the tenant app's /auth pipeline. Self-test uses it to
67
+ * materialize `requiresConnection` credentials through the provider's declared
68
+ * auth flow — the exact path production connections take — instead of
69
+ * injecting raw credential inputs as connection secrets.
70
+ */
71
+ export type SelfTestAuthFlowInvoke = (args: {
72
+ route: SelfTestAuthFlowRoute;
73
+ requestId: string;
74
+ flowId: string;
75
+ /** Stable per-credential connection id — keeps login on the probe's affinity. */
76
+ connectionId?: string;
77
+ /** The probe connection's externalRef — flows reading ctx.externalRef see the same identity. */
78
+ externalRef?: string;
79
+ input?: Record<string, unknown>;
80
+ context?: Record<string, unknown>;
81
+ }) => Promise<{
82
+ status: number;
83
+ body: unknown;
84
+ }>;
85
+ /**
86
+ * Skip reason reported when a declared auth flow does not complete in a single
87
+ * continue (OTP, retry loop). Cross-repo contract: the health-monitor maps
88
+ * this exact string to `self_test_incapable`; never vary it.
89
+ */
90
+ export declare const SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON = "auth_flow_multi_turn";
91
+ /**
92
+ * A `retry` turn after credential submission: the flow REJECTED the
93
+ * configured inputs (bad password, exchange failure). Distinct from the
94
+ * multi-turn gap so monitoring surfaces it as a real credential outage, and
95
+ * memoized like multi-turn so the probe does not re-submit rejected
96
+ * credentials every cycle (lockout safety).
97
+ */
98
+ export declare const SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON = "auth_flow_rejected";
64
99
  export interface SelfTestAppOptions {
65
100
  /** Derived-token verification secrets; without them every self-test route 404s. */
66
101
  secrets?: SelfTestMasterSecrets;
67
102
  /** In-process invoke bound to the tenant-facing app's /v1 pipeline. */
68
103
  invoke: SelfTestOperationInvoke;
104
+ /**
105
+ * In-process auth-flow driver bound to the tenant-facing app's /auth
106
+ * pipeline. Required for providers that declare `auth.mode: "credentials"`
107
+ * with a flow; without it their requiresConnection cases report a visible
108
+ * auth_flow_unavailable error instead of probing with raw inputs.
109
+ */
110
+ authFlow?: SelfTestAuthFlowInvoke;
69
111
  /** Overall request budget; defaults to env / 120s. */
70
112
  requestBudgetMs?: number;
71
113
  /** Env override for secret collection + budget resolution (tests). */
@@ -88,6 +130,65 @@ export declare function isSelfTestReadOnlyOperation(operation: OperationDefiniti
88
130
  export declare function createSelfTestInvoke(app: {
89
131
  request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
90
132
  }): SelfTestOperationInvoke;
133
+ /** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
134
+ export declare function createSelfTestAuthFlowInvoke(app: {
135
+ request: (input: string, requestInit?: RequestInit) => Response | Promise<Response>;
136
+ }): SelfTestAuthFlowInvoke;
137
+ /**
138
+ * How long a memoized multi-turn flow outcome suppresses re-driving the auth
139
+ * flow. Generous on purpose: a multi-turn ceremony (OTP, device approval) is a
140
+ * provider property that changes on the timescale of releases, not probe
141
+ * cycles, and every re-drive is a REAL upstream login submission. The cache is
142
+ * in-process, so a pod restart also clears the entry.
143
+ */
144
+ export declare const SELF_TEST_MULTI_TURN_RETRY_AFTER_MS: number;
145
+ /**
146
+ * Age bound for POSITIVE cached credentials. Expiry modes that never produce
147
+ * a 401/403 (a 200 login page, an assertion failure) would otherwise replay
148
+ * the same stale session until pod restart — one re-login per day is the
149
+ * upstream-safe recovery for them.
150
+ */
151
+ export declare const SELF_TEST_CREDENTIAL_MAX_AGE_MS: number;
152
+ export type SelfTestCredentialSessionEntry =
153
+ /** Flow-materialized credential reused across probe cycles. */
154
+ {
155
+ kind: "credential";
156
+ credential: Record<string, string>;
157
+ cachedAtMs: number;
158
+ }
159
+ /**
160
+ * Negative entry: the flow did not complete in a single continue turn
161
+ * (`auth_flow_multi_turn`). Memoized so subsequent cycles report the skip
162
+ * WITHOUT contacting the upstream again — the first attempt already
163
+ * submitted real credentials (and may have triggered an OTP send).
164
+ */
165
+ | {
166
+ kind: "multi_turn";
167
+ cachedAtMs: number;
168
+ }
169
+ /**
170
+ * Negative entry: the flow REJECTED the submitted credential inputs
171
+ * (`retry` turn after continue — bad password, exchange failure).
172
+ * Memoized so the probe does not re-submit rejected credentials every
173
+ * cycle; a new entry is attempted when the inputs rotate (new hash),
174
+ * the TTL lapses, or the process restarts.
175
+ */
176
+ | {
177
+ kind: "rejected";
178
+ cachedAtMs: number;
179
+ };
180
+ /**
181
+ * In-process cache of per-(providerId + stable hash of credentialInputs) auth
182
+ * flow outcomes, so consecutive probe cycles reuse the session — or the
183
+ * memoized multi-turn skip — instead of logging in every cycle (upstream
184
+ * account safety, DR-7). Credential entries are invalidated on a probe auth
185
+ * failure, at most once; multi-turn entries expire after
186
+ * `SELF_TEST_MULTI_TURN_RETRY_AFTER_MS` or on process restart. Flow ERRORS
187
+ * (transport/protocol failures, thrown start/continue) are deliberately NEVER
188
+ * cached: they are typically transient, and retrying a failed request next
189
+ * cycle is not a repeated login submission.
190
+ */
191
+ export type SelfTestCredentialSessionCache = Map<string, SelfTestCredentialSessionEntry>;
91
192
  export declare function resolveSelfTestPort(env?: Readonly<Record<string, string | undefined>>): number;
92
193
  /**
93
194
  * Builds the internal self-test Hono app. This app is served on a SEPARATE