@apifuse/provider-sdk 2.2.0-beta.65 → 2.2.0-beta.67

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.67
4
+
5
+ - Release candidate for main commit 9ef92b86b293253597801e29764d76beb0a3f3b7.
6
+
7
+ ## 2.2.0-beta.66
8
+
9
+ - Release candidate for main commit e7f93a6c71e3bca3a680861c0968e22dbe5d1d40.
10
+
3
11
  ## 2.2.0-beta.65
4
12
 
5
13
  - Release candidate for main commit 4731ac76145caf85a6c340ad0c446371bd24b052.
@@ -588,12 +588,56 @@ function checkPackageJson(packageJsonPath: string): CheckResult {
588
588
 
589
589
  export const PROVIDER_JSON_CHECK_MESSAGE = "provider.json exists with a valid declaration";
590
590
 
591
+ /**
592
+ * Markets a provider may declare as its primary, and the locales the platform
593
+ * catalogs can carry.
594
+ *
595
+ * These mirror `scripts/provider-deploy/provider-locale-coverage.mjs` in the
596
+ * APIFuse monorepo, which is the policy's source of truth. The duplication is
597
+ * deliberate and bounded: the monorepo cannot be imported from a provider
598
+ * checkout, and a *closed* mirror is the point — it keeps a typo
599
+ * (`primaryMarket: "jp-JP"`) a local failure instead of one that only surfaces
600
+ * in the monorepo's contract validator days later.
601
+ */
602
+ const PROVIDER_PRIMARY_MARKETS = ["jp", "kr", "global"] as const;
603
+ const PROVIDER_COVERAGE_LOCALES = ["en", "ko", "ja"] as const;
604
+ const PROVIDER_BASELINE_LOCALE = "en";
605
+
606
+ /**
607
+ * Declared locale coverage, optional at schemaVersion 1.
608
+ *
609
+ * `apifuse provider declare-locale-coverage` writes this block, and the
610
+ * monorepo declaration schema has accepted it since the rollout landed. This
611
+ * check used to close `provider.json` over four keys, which made the only
612
+ * documented way to clear the monorepo's `LOCALE_COVERAGE_UNDECLARED` finding
613
+ * turn a green `bun run check` red — the declaration was unfixable from either
614
+ * side. The fix is to mirror the monorepo schema rather than to stop
615
+ * validating: the block stays closed over its own two keys so a misspelled
616
+ * field is still caught here, at authoring time.
617
+ */
618
+ const providerI18nDeclarationSchema = z
619
+ .object({
620
+ primaryMarket: z.enum(PROVIDER_PRIMARY_MARKETS),
621
+ locales: z
622
+ .array(z.enum(PROVIDER_COVERAGE_LOCALES))
623
+ .nonempty()
624
+ .refine((locales) => new Set(locales).size === locales.length, {
625
+ message: "i18n.locales must not repeat a locale",
626
+ })
627
+ .refine((locales) => locales.includes(PROVIDER_BASELINE_LOCALE), {
628
+ message: `i18n.locales must include "${PROVIDER_BASELINE_LOCALE}": it is the fallback every other locale resolves through`,
629
+ })
630
+ .optional(),
631
+ })
632
+ .strict();
633
+
591
634
  const providerDeclarationSchema = z
592
635
  .object({
593
636
  schemaVersion: z.literal(1),
594
637
  providerId: z.string(),
595
638
  owner: z.string(),
596
639
  lifecycle: z.enum(["draft", "ready", "live", "retired"]),
640
+ i18n: providerI18nDeclarationSchema.optional(),
597
641
  })
598
642
  .strict();
599
643
 
@@ -624,7 +668,17 @@ function checkProviderJson(providerJsonPath: string, packageJsonPath: string): C
624
668
  return {
625
669
  message: PROVIDER_JSON_CHECK_MESSAGE,
626
670
  passed: true,
627
- details: [`providerId: ${declaration.providerId}`, `lifecycle: ${declaration.lifecycle}`],
671
+ details: [
672
+ `providerId: ${declaration.providerId}`,
673
+ `lifecycle: ${declaration.lifecycle}`,
674
+ ...(declaration.i18n
675
+ ? [
676
+ `locale coverage: primaryMarket ${declaration.i18n.primaryMarket}${
677
+ declaration.i18n.locales ? ` (locales: ${declaration.i18n.locales.join(", ")})` : ""
678
+ }`,
679
+ ]
680
+ : []),
681
+ ],
628
682
  };
629
683
  } catch (error) {
630
684
  return {
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
 
9
9
  import {
10
10
  createBypassProviderCache,
11
+ createCredentialContext,
11
12
  createHttpClient,
12
13
  createInProcessProviderEngine,
13
14
  createOcrClientFromEnv,
@@ -27,6 +28,13 @@ import {
27
28
  TransportError,
28
29
  ValidationError,
29
30
  } from "../src/index.js";
31
+ import {
32
+ type LocalConnection,
33
+ LOCAL_CONNECTION_ID,
34
+ LOCAL_CONNECTION_SECRETS_ENV,
35
+ providerRequiresConnection,
36
+ resolveLocalConnection,
37
+ } from "../src/cli/local-connection.js";
30
38
  import { createCliResolverRuntime } from "../src/cli/resolver-runtime.js";
31
39
  import { assertProcessEngineModeSupported } from "../src/runtime/engine-mode.js";
32
40
  import type { JsonValue } from "../src/contract-json.js";
@@ -71,6 +79,10 @@ type CliArgs = {
71
79
  operation?: string;
72
80
  params: string;
73
81
  sanitize: boolean;
82
+ /** Repeated `--credential key=value`, in order. */
83
+ credentials: string[];
84
+ /** `--no-credential`: run without a connection even when auth declares one. */
85
+ noCredential: boolean;
74
86
  };
75
87
 
76
88
  type ProviderRuntime = ProviderDefinition;
@@ -94,8 +106,18 @@ Options:
94
106
  --append preserve the existing fixture and append this capture
95
107
  --sanitize redact common token/header fields (default)
96
108
  --no-sanitize disable common-field redaction (sensitiveParams are always redacted)
109
+ --credential <k>=<v> credential the gateway would inject (repeatable)
110
+ --no-credential record without a credential even when auth declares one
97
111
  --help, -h show this help
98
112
 
113
+ Credentials:
114
+ A provider whose auth.mode is not "none" reads a credential the gateway
115
+ resolves and injects at invocation time -- for "platform-managed" that is the
116
+ platform-owned key, which is not a provider secret and is not in the
117
+ checkout. Supply it with --credential, or with
118
+ ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'. The keys are the ones
119
+ the provider passes to ctx.credential.get(...).
120
+
99
121
  Example:
100
122
  apifuse record providers/korea-air-quality --operation realtime --params '{"stationName":"jongno"}'`;
101
123
 
@@ -111,10 +133,13 @@ export async function main() {
111
133
  const operation = provider.operations[operationName];
112
134
  const parsedParams = await parseParams(operation, args.params);
113
135
 
136
+ const connection = resolveRecordConnection(provider, args);
137
+
114
138
  capture = createCaptureContext(
115
139
  provider,
116
140
  resolveOperationBaseUrl(provider, operationName),
117
141
  args.sanitize,
142
+ connection,
118
143
  );
119
144
 
120
145
  console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
@@ -181,6 +206,41 @@ export async function main() {
181
206
  }
182
207
  }
183
208
 
209
+ /**
210
+ * The connection this run attaches, or a hard failure when the provider needs
211
+ * one and none is configured.
212
+ *
213
+ * Failing here rather than at the handler is the whole point. Before this, a
214
+ * platform-managed provider recorded with `credential: { mode: "none" }`, so
215
+ * `ctx.credential.get(...)` returned `undefined` and the run died on the
216
+ * provider's own `MISSING_SECRET` — indistinguishable, to the author, from an
217
+ * upstream outage or a bad key. It reported as a capability the recorder
218
+ * lacked only if you already knew to look.
219
+ */
220
+ function resolveRecordConnection(
221
+ provider: ProviderRuntime,
222
+ args: CliArgs,
223
+ ): LocalConnection | undefined {
224
+ if (args.noCredential) {
225
+ if (providerRequiresConnection(provider)) {
226
+ console.warn(
227
+ `[apifuse record] --no-credential: recording ${provider.id} without the credential its auth.mode "${provider.auth?.mode}" declares.`,
228
+ );
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ const resolution = resolveLocalConnection(provider, {
234
+ overrides: args.credentials,
235
+ });
236
+ if (!resolution.ok)
237
+ throw new ProviderError(resolution.reason, {
238
+ code: "CREDENTIAL_UNAVAILABLE",
239
+ fix: "Supply the credential with --credential or the environment variable named above.",
240
+ });
241
+ return resolution.connection;
242
+ }
243
+
184
244
  function normalizeArgs(argv: string[]): string[] {
185
245
  return argv[0] === "record" ? argv.slice(1) : argv;
186
246
  }
@@ -191,6 +251,8 @@ function parseArgs(argv: string[]): CliArgs {
191
251
  let params = "{}";
192
252
  let sanitize = true;
193
253
  let append = false;
254
+ const credentials: string[] = [];
255
+ let noCredential = false;
194
256
 
195
257
  for (let index = 0; index < argv.length; index += 1) {
196
258
  const arg = argv[index];
@@ -247,6 +309,26 @@ function parseArgs(argv: string[]): CliArgs {
247
309
  continue;
248
310
  }
249
311
 
312
+ if (arg === "--credential") {
313
+ const value = argv[index + 1];
314
+ if (!value) {
315
+ throw new Error("Missing value for --credential.");
316
+ }
317
+ credentials.push(value);
318
+ index += 1;
319
+ continue;
320
+ }
321
+
322
+ if (arg.startsWith("--credential=")) {
323
+ credentials.push(arg.slice("--credential=".length));
324
+ continue;
325
+ }
326
+
327
+ if (arg === "--no-credential") {
328
+ noCredential = true;
329
+ continue;
330
+ }
331
+
250
332
  if (arg.startsWith("-")) {
251
333
  throw new Error(`Unknown option: ${arg}`);
252
334
  }
@@ -259,7 +341,15 @@ function parseArgs(argv: string[]): CliArgs {
259
341
  throw new Error(`Unexpected argument: ${arg}`);
260
342
  }
261
343
 
262
- return { append, providerPath, operation, params, sanitize };
344
+ return {
345
+ append,
346
+ providerPath,
347
+ operation,
348
+ params,
349
+ sanitize,
350
+ credentials,
351
+ noCredential,
352
+ };
263
353
  }
264
354
 
265
355
  /**
@@ -459,6 +549,7 @@ export function createCaptureContext(
459
549
  provider: ProviderRuntime,
460
550
  baseUrl: string,
461
551
  sanitize: boolean,
552
+ connection?: LocalConnection,
462
553
  ) {
463
554
  let nextCaptureOrder = 0;
464
555
  let nextStreamOrdinal = 0;
@@ -477,6 +568,25 @@ export function createCaptureContext(
477
568
  provider,
478
569
  cache,
479
570
  );
571
+ // The injected credential is the most sensitive value in the run, and the
572
+ // recorder prints provider errors and writes fixtures. Seed its VALUES into
573
+ // the redaction set before anything can execute, so an upstream error that
574
+ // echoes the key, and any fixture that captured it, are redacted by the same
575
+ // machinery that already covers declared query secrets.
576
+ //
577
+ // Values only, never the key names: `sensitiveParamNames` means "declared
578
+ // query-key position", and `redactFixture` deliberately skips common-field
579
+ // sanitization for a key that appears in it. Seeding `access_token` there
580
+ // would therefore write a *newly issued* upstream `access_token` — which
581
+ // does not equal the injected value — to raw.json in plaintext. A credential
582
+ // key that really is a query parameter is added to the name set by
583
+ // `captureSensitiveRequestValues` when the provider sends it, which is the
584
+ // only place that claim is true.
585
+ for (const value of Object.values(connection?.secrets ?? {})) {
586
+ if (value.length > 0) sensitiveParamValues.add(value);
587
+ }
588
+ registerSensitiveValues([...sensitiveParamValues]);
589
+
480
590
  const captureSensitiveParams = (url: string, options?: SensitiveRequestOptions) => {
481
591
  try {
482
592
  captureSensitiveRequestValues(url, options, sensitiveParamNames, sensitiveParamValues);
@@ -550,18 +660,21 @@ export function createCaptureContext(
550
660
  provider.secrets?.map((secret) => secret.name) ?? [],
551
661
  );
552
662
  const env = { get: (key: string) => readDiagnosticEnv(key, providerEnvironment) };
553
- const credential = {
554
- mode: "none" as const,
555
- get: () => undefined,
556
- getAll: () => ({}),
557
- getAccessToken: () => undefined,
558
- getScopes: () => [],
559
- };
663
+ // Built exactly the way `serve` builds it from a gateway-supplied
664
+ // `request.connection`, so the provider takes its production branch rather
665
+ // than the always-undefined one a stub credential forces.
666
+ const credential = createCredentialContext({
667
+ ...(provider.credential?.keys ? { allowedKeys: provider.credential.keys } : {}),
668
+ ...(connection ? { mode: connection.mode, values: connection.secrets } : {}),
669
+ });
560
670
  const state = createMemoryProviderRuntimeState();
561
671
  const candidates: ProviderEngineBindingCandidates = {
562
672
  env,
563
673
  credential,
564
- request: { headers: {}, connectionId: "local-record" },
674
+ request: {
675
+ headers: {},
676
+ connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
677
+ },
565
678
  http,
566
679
  cache,
567
680
  state,
@@ -595,7 +708,10 @@ export function createCaptureContext(
595
708
  resolver,
596
709
  handle: createHandleContext({
597
710
  providerId: provider.id,
598
- request: { headers: {}, connectionId: "local-record" },
711
+ request: {
712
+ headers: {},
713
+ connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
714
+ },
599
715
  state,
600
716
  }),
601
717
  };
@@ -12,6 +12,11 @@ import type TS from "typescript";
12
12
  import { z } from "zod";
13
13
 
14
14
  import packageJson from "../package.json";
15
+ import {
16
+ LOCAL_CONNECTION_SECRETS_ENV,
17
+ providerRequiresConnection,
18
+ resolveLocalConnection,
19
+ } from "../src/cli/local-connection.js";
15
20
  import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
16
21
  import {
17
22
  loadProviderLocaleCatalogs,
@@ -125,6 +130,25 @@ export type SmokeResult = {
125
130
  healthOk: boolean;
126
131
  bootError?: string;
127
132
  operations: SmokeOperationOutcome[];
133
+ /**
134
+ * Whether the provider's declared auth mode needs a gateway-injected
135
+ * credential, and whether this run had one. Recorded so a provider that
136
+ * could not be exercised at all says so, instead of scoring as "ran, but
137
+ * saw no live success" — the two are not the same evidence.
138
+ */
139
+ credential?: {
140
+ required: boolean;
141
+ supplied: boolean;
142
+ mode: string;
143
+ /**
144
+ * Why the local credential could not be resolved — a malformed
145
+ * `APIFUSE__LOCAL_CONNECTION__SECRETS`, a non-string value, a bad
146
+ * `--credential`. Distinct from "not configured": a misconfiguration
147
+ * reported as an absence sends the author looking for a key they already
148
+ * set.
149
+ */
150
+ error?: string;
151
+ };
128
152
  };
129
153
 
130
154
  type SourceFinding = {
@@ -156,7 +180,9 @@ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold
156
180
  Alias: apifuse bounty-check [path]
157
181
  Default: apifuse submit-check .
158
182
 
159
- Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.`;
183
+ Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.
184
+
185
+ A provider whose auth.mode is not "none" reads a credential the gateway injects at invocation time; smoke attaches the same connection from ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', keyed as the provider reads them through ctx.credential.get(...). Without it no operation reaches the upstream, and the smoke check says so rather than scoring it as a missing live success.`;
160
186
 
161
187
  export async function main() {
162
188
  try {
@@ -1007,11 +1033,9 @@ function countAsAssertions(providerRoot: string): {
1007
1033
 
1008
1034
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1009
1035
  const content = readFileSync(filePath, "utf8");
1010
- const lines = maskCommentsAndStrings(
1011
- content,
1012
- toRelativeProviderPath(providerRoot, filePath),
1013
- { blankPropertyKeys: true },
1014
- ).split(/\r?\n/);
1036
+ const lines = maskCommentsAndStrings(content, toRelativeProviderPath(providerRoot, filePath), {
1037
+ blankPropertyKeys: true,
1038
+ }).split(/\r?\n/);
1015
1039
  for (let index = 0; index < lines.length; index += 1) {
1016
1040
  const line = lines[index];
1017
1041
  if (
@@ -2017,18 +2041,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2017
2041
  );
2018
2042
  if (opaqueSpread !== undefined) {
2019
2043
  const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2020
- return escapeHatchResult(
2021
- providerRoot,
2022
- ruleId,
2023
- [{ file: indexRelPath, line: spreadLine }],
2024
- {
2025
- blockerMessage:
2026
- "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2027
- remediation:
2028
- "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2029
- passMessage: "defineProvider declares operations as a static object literal.",
2030
- },
2031
- );
2044
+ return escapeHatchResult(providerRoot, ruleId, [{ file: indexRelPath, line: spreadLine }], {
2045
+ blockerMessage:
2046
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2047
+ remediation:
2048
+ "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2049
+ passMessage: "defineProvider declares operations as a static object literal.",
2050
+ });
2032
2051
  }
2033
2052
 
2034
2053
  // Determine the effective initializer expression to classify. The alias may
@@ -2079,11 +2098,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2079
2098
  });
2080
2099
 
2081
2100
  const declRe = new RegExp(aliasDecl.source, "g");
2082
- for (
2083
- let m = declRe.exec(maskedFileSource);
2084
- m !== null;
2085
- m = declRe.exec(maskedFileSource)
2086
- ) {
2101
+ for (let m = declRe.exec(maskedFileSource); m !== null; m = declRe.exec(maskedFileSource)) {
2087
2102
  const valueStart = m.index + m[0].length;
2088
2103
  const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
2089
2104
  const isFactory =
@@ -2133,9 +2148,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2133
2148
  const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2134
2149
  blankPropertyKeys: true,
2135
2150
  });
2136
- const importMatch = new RegExp(
2137
- `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
2138
- ).exec(maskedIndexSource);
2151
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
2152
+ maskedIndexSource,
2153
+ );
2139
2154
  if (importMatch) {
2140
2155
  const raw = `${aliasName}(`;
2141
2156
  effective = { raw, masked: raw };
@@ -3303,12 +3318,7 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
3303
3318
  if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
3304
3319
  continue;
3305
3320
  }
3306
- for (const keyFinding of vendorKeyFindingsForObject(
3307
- source,
3308
- sourceFile,
3309
- zObject,
3310
- bindings,
3311
- )) {
3321
+ for (const keyFinding of vendorKeyFindingsForObject(source, sourceFile, zObject, bindings)) {
3312
3322
  const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
3313
3323
  if (!seen.has(key)) {
3314
3324
  seen.add(key);
@@ -4088,11 +4098,7 @@ export function maskCommentsAndStrings(
4088
4098
  return masked;
4089
4099
  }
4090
4100
 
4091
- function computeMaskedSource(
4092
- source: string,
4093
- fileName: string,
4094
- blankPropertyKeys: boolean,
4095
- ): string {
4101
+ function computeMaskedSource(source: string, fileName: string, blankPropertyKeys: boolean): string {
4096
4102
  const transpiled = ts.transpileModule(source, {
4097
4103
  // Declaration files trigger an internal TypeScript Debug Failure when
4098
4104
  // passed to transpileModule. Parsing is all we need here, so always use a
@@ -4125,10 +4131,7 @@ function computeMaskedSource(
4125
4131
  // by LF, CRLF, or lone CR) and every line terminator, so masked
4126
4132
  // string bodies remain syntactically valid and the mask output can
4127
4133
  // be re-parsed (mask(mask(x)) === mask(x)).
4128
- if (
4129
- source[index] === "\\" &&
4130
- (source[index + 1] === "\n" || source[index + 1] === "\r")
4131
- ) {
4134
+ if (source[index] === "\\" && (source[index + 1] === "\n" || source[index + 1] === "\r")) {
4132
4135
  continue;
4133
4136
  }
4134
4137
  if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
@@ -4150,8 +4153,7 @@ function computeMaskedSource(
4150
4153
  // Preserve quoted property keys ("response": ...) by default so range/key
4151
4154
  // scanners can still match them. Line scanners opt into blanking key bodies
4152
4155
  // as well, preventing key text from looking like executable source.
4153
- const isQuotedPropertyKey =
4154
- ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4156
+ const isQuotedPropertyKey = ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4155
4157
  if (blankPropertyKeys || !isQuotedPropertyKey) {
4156
4158
  maskRange(start + 1, node.end - 1);
4157
4159
  } else {
@@ -5026,7 +5028,7 @@ function isAstNode(value: unknown): value is acorn.AnyNode {
5026
5028
  );
5027
5029
  }
5028
5030
 
5029
- function scoreSmoke(
5031
+ export function scoreSmoke(
5030
5032
  smokeResult: SmokeResult | undefined,
5031
5033
  smokeNote: string | undefined,
5032
5034
  ): SubmitCheck {
@@ -5048,8 +5050,22 @@ function scoreSmoke(
5048
5050
  };
5049
5051
  }
5050
5052
 
5053
+ const credential = smokeResult.credential;
5054
+ // An invalid configuration is reported even for a provider that needs no
5055
+ // credential: the operator set something, and silence would read as accepted.
5056
+ const credentialEvidence =
5057
+ credential?.required === true || credential?.error !== undefined
5058
+ ? [
5059
+ credential.supplied
5060
+ ? `credential: supplied for auth.mode "${credential.mode}"`
5061
+ : credential.error
5062
+ ? `credential: CONFIGURED BUT INVALID — ${credential.error}`
5063
+ : `credential: NOT supplied; auth.mode "${credential.mode}" needs one the gateway injects at invocation time, so every credentialed operation failed before reaching the upstream`,
5064
+ ]
5065
+ : [];
5051
5066
  const evidence = [
5052
5067
  `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
5068
+ ...credentialEvidence,
5053
5069
  ...smokeResult.operations.map(
5054
5070
  (outcome) =>
5055
5071
  `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
@@ -5073,6 +5089,31 @@ function scoreSmoke(
5073
5089
  };
5074
5090
  }
5075
5091
 
5092
+ // Checked BEFORE the success branch. A provider that needs a
5093
+ // gateway-injected credential and did not get one never exercised its
5094
+ // credentialed path, so a schema-valid success from some other operation --
5095
+ // an unauthenticated one, or a fixture-shaped response -- must not buy full
5096
+ // smoke points. Saying "verified" would credit the run with evidence it does
5097
+ // not have.
5098
+ if (credential?.required === true && !credential.supplied) {
5099
+ return {
5100
+ id: "local-smoke",
5101
+ category: "smoke",
5102
+ level: "warn",
5103
+ status: "warn",
5104
+ points: 7,
5105
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
5106
+ message: credential.error
5107
+ ? `Runtime path was verified, but the configured local credential could not be used, so the provider's credentialed operations never reached the upstream.`
5108
+ : `Runtime path was verified, but auth.mode "${credential.mode}" needs a credential this run did not have, so the provider's credentialed operations never reached the upstream.`,
5109
+ remediation: credential.error
5110
+ ? `Fix the local credential configuration and rerun \`bun run submit-check -- --smoke\`: ${credential.error}`
5111
+ : `Supply the credential the gateway injects and rerun \`bun run submit-check -- --smoke\`: ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', where the keys are the ones the provider passes to ctx.credential.get(...). For platform-managed providers that value is the platform-owned key an operator holds; it is not in the checkout.`,
5112
+ evidence,
5113
+ details: smokeResult,
5114
+ };
5115
+ }
5116
+
5076
5117
  const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
5077
5118
  if (successes.length > 0) {
5078
5119
  return {
@@ -5143,6 +5184,12 @@ export async function runSubmitCheckSmoke(
5143
5184
  operations: [],
5144
5185
  };
5145
5186
  }
5187
+ // The gateway attaches a resolved credential to every invocation of a
5188
+ // provider whose auth.mode is not "none". Smoke POSTed an envelope with
5189
+ // no `connection` at all, so every platform-managed provider answered
5190
+ // MISSING_SECRET and scored as "runtime verified, no live success" —
5191
+ // a capability gap wearing the costume of an ordinary upstream miss.
5192
+ const connection = resolveLocalConnection(loadedProvider);
5146
5193
  const operations: SmokeOperationOutcome[] = [];
5147
5194
  for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
5148
5195
  operations.push(
@@ -5150,10 +5197,21 @@ export async function runSubmitCheckSmoke(
5150
5197
  requestId: `req_submit_check_smoke_${operationId}`,
5151
5198
  input: operation.fixtures?.request ?? {},
5152
5199
  headers: {},
5200
+ ...(connection.ok && connection.connection ? { connection: connection.connection } : {}),
5153
5201
  }),
5154
5202
  );
5155
5203
  }
5156
- return { measured: true, healthOk: true, operations };
5204
+ return {
5205
+ measured: true,
5206
+ healthOk: true,
5207
+ operations,
5208
+ credential: {
5209
+ required: providerRequiresConnection(loadedProvider),
5210
+ supplied: connection.ok && connection.connection !== undefined,
5211
+ mode: loadedProvider.auth?.mode ?? "none",
5212
+ ...(connection.ok ? {} : { error: connection.reason }),
5213
+ },
5214
+ };
5157
5215
  } finally {
5158
5216
  await stopSmokeServer(server);
5159
5217
  }
@@ -5994,8 +6052,7 @@ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
5994
6052
  const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
5995
6053
  const relativePath = relative(providerRoot, resolve(pathCandidate));
5996
6054
  const insideProvider =
5997
- relativePath === "" ||
5998
- (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
6055
+ relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
5999
6056
  return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
6000
6057
  },
6001
6058
  );
@@ -0,0 +1,111 @@
1
+ import type { AuthMode, ProviderDefinition } from "../types.js";
2
+ /**
3
+ * The connection the local CLIs stand in for the gateway with.
4
+ *
5
+ * At invocation time the gateway resolves the credential a provider needs and
6
+ * attaches it to the request envelope as
7
+ * `connection: { id, mode, secrets, metadata }`; `serve` then builds
8
+ * `ctx.credential` from `connection.secrets` and nothing else. For
9
+ * `auth.mode: "platform-managed"` the secrets are the platform-owned key the
10
+ * caller never sees — which is why a probe reaches one only by going back
11
+ * through the gateway (`ctx.gateway.execute(...)` in a health case's
12
+ * `prepareInput`), and why nothing that runs outside the gateway had any way
13
+ * to supply it.
14
+ *
15
+ * `apifuse record` hard-coded `credential: { mode: "none" }`, so
16
+ * `ctx.credential.get(...)` returned `undefined` for every platform-managed
17
+ * provider and the run died on that provider's own `MISSING_SECRET`. The same
18
+ * hole made `submit-check --smoke` POST an envelope with no `connection` at
19
+ * all. A large share of the fleet was unrecordable and unsmokeable, and it
20
+ * read as "no live upstream success" rather than as a missing capability.
21
+ *
22
+ * This module is the local resolver for that connection. It does not invent a
23
+ * second credential path: it produces the exact shape the gateway injects, so
24
+ * the provider code under test takes the production branch.
25
+ */
26
+ export interface LocalConnection {
27
+ readonly id: string;
28
+ readonly mode: AuthMode;
29
+ readonly secrets: Record<string, string>;
30
+ readonly metadata: Record<string, never>;
31
+ /**
32
+ * Empty, exactly as the gateway leaves it when it injects a platform
33
+ * credential. `OperationConnectionSchema` requires the field, so omitting
34
+ * it would make every smoke envelope fail request validation before the
35
+ * provider ran.
36
+ */
37
+ readonly externalRef: string;
38
+ }
39
+ /**
40
+ * JSON object of `connection.secrets`, keyed exactly as the provider reads
41
+ * them through `ctx.credential.get(...)`.
42
+ *
43
+ * A single JSON value rather than one variable per key on purpose:
44
+ * platform-managed providers must not declare `credential.keys` (the SDK lint
45
+ * rejects it), so the key names are known only to the provider's own source.
46
+ * There is no derivation from an env name that would be right for every
47
+ * provider, and guessing one would be a second, lossy contract next to the
48
+ * gateway's.
49
+ */
50
+ export declare const LOCAL_CONNECTION_SECRETS_ENV = "APIFUSE__LOCAL_CONNECTION__SECRETS";
51
+ /** Stable local connection id, mirroring the CLIs' other `local-*` scopes. */
52
+ export declare const LOCAL_CONNECTION_ID = "local-connection";
53
+ /** Auth modes whose providers read a credential the gateway supplies. */
54
+ export declare function providerRequiresConnection(provider: {
55
+ readonly auth?: {
56
+ readonly mode?: AuthMode;
57
+ } | undefined;
58
+ }): boolean;
59
+ export type CredentialOverrideParse = {
60
+ readonly ok: true;
61
+ readonly secrets: Record<string, string>;
62
+ } | {
63
+ readonly ok: false;
64
+ readonly reason: string;
65
+ };
66
+ /**
67
+ * Parses repeated `--credential key=value` arguments.
68
+ *
69
+ * Splits on the first `=` only: a base64 or URL-shaped secret routinely
70
+ * contains more.
71
+ */
72
+ export declare function parseCredentialOverrides(values: readonly string[]): CredentialOverrideParse;
73
+ /** Reads {@link LOCAL_CONNECTION_SECRETS_ENV} into a secrets map. */
74
+ export declare function parseEnvironmentSecrets(raw: string | undefined): CredentialOverrideParse;
75
+ export interface ResolveLocalConnectionOptions {
76
+ /** Repeated `--credential key=value` arguments, in order. */
77
+ readonly overrides?: readonly string[];
78
+ /** Environment to read {@link LOCAL_CONNECTION_SECRETS_ENV} from. */
79
+ readonly env?: Readonly<Record<string, string | undefined>>;
80
+ }
81
+ export type LocalConnectionResolution = {
82
+ readonly ok: true;
83
+ readonly connection: LocalConnection | undefined;
84
+ } | {
85
+ readonly ok: false;
86
+ readonly reason: string;
87
+ };
88
+ /**
89
+ * Resolves the connection to attach for one local invocation.
90
+ *
91
+ * Three outcomes, and the middle one is the point of the change:
92
+ *
93
+ * - the provider needs no credential (`auth.mode` absent or `"none"`) →
94
+ * no connection, exactly as before;
95
+ * - the provider needs one and none is configured → `ok: false` with a
96
+ * message naming both ways to supply it. The caller surfaces that instead
97
+ * of letting the provider fail deep inside a handler on a `MISSING_SECRET`
98
+ * that reads like an upstream problem;
99
+ * - the provider needs one and it is configured → the gateway's envelope
100
+ * shape.
101
+ *
102
+ * CLI overrides win over the environment so a one-off run does not need the
103
+ * shell variable unset.
104
+ */
105
+ export declare function resolveLocalConnection(provider: Pick<ProviderDefinition, "id"> & {
106
+ auth?: {
107
+ mode?: AuthMode;
108
+ };
109
+ }, options?: ResolveLocalConnectionOptions): LocalConnectionResolution;
110
+ /** The message a caller prints when a needed credential is not configured. */
111
+ export declare function missingLocalConnectionMessage(providerId: string, mode: AuthMode): string;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * JSON object of `connection.secrets`, keyed exactly as the provider reads
3
+ * them through `ctx.credential.get(...)`.
4
+ *
5
+ * A single JSON value rather than one variable per key on purpose:
6
+ * platform-managed providers must not declare `credential.keys` (the SDK lint
7
+ * rejects it), so the key names are known only to the provider's own source.
8
+ * There is no derivation from an env name that would be right for every
9
+ * provider, and guessing one would be a second, lossy contract next to the
10
+ * gateway's.
11
+ */
12
+ export const LOCAL_CONNECTION_SECRETS_ENV = "APIFUSE__LOCAL_CONNECTION__SECRETS";
13
+ /** Stable local connection id, mirroring the CLIs' other `local-*` scopes. */
14
+ export const LOCAL_CONNECTION_ID = "local-connection";
15
+ /** Auth modes whose providers read a credential the gateway supplies. */
16
+ export function providerRequiresConnection(provider) {
17
+ const mode = provider.auth?.mode;
18
+ return mode !== undefined && mode !== "none";
19
+ }
20
+ /**
21
+ * Parses repeated `--credential key=value` arguments.
22
+ *
23
+ * Splits on the first `=` only: a base64 or URL-shaped secret routinely
24
+ * contains more.
25
+ */
26
+ export function parseCredentialOverrides(values) {
27
+ const secrets = {};
28
+ for (const [index, value] of values.entries()) {
29
+ // The offending argument is never echoed. `--credential =secret` and
30
+ // `--credential secret` are both malformed *and* carry the credential, so
31
+ // quoting the input to be helpful would print it to stderr — before any
32
+ // redaction context exists, so no caller could scrub it.
33
+ const separator = value.indexOf("=");
34
+ if (separator <= 0) {
35
+ return {
36
+ ok: false,
37
+ reason: `--credential #${index + 1} is not of the form key=value; its value is not echoed here because it may be the credential.`,
38
+ };
39
+ }
40
+ const key = value.slice(0, separator).trim();
41
+ if (key.length === 0) {
42
+ return {
43
+ ok: false,
44
+ reason: `--credential #${index + 1} has an empty key; its value is not echoed here because it may be the credential.`,
45
+ };
46
+ }
47
+ secrets[key] = value.slice(separator + 1);
48
+ }
49
+ return { ok: true, secrets };
50
+ }
51
+ /** Reads {@link LOCAL_CONNECTION_SECRETS_ENV} into a secrets map. */
52
+ export function parseEnvironmentSecrets(raw) {
53
+ if (raw === undefined || raw.trim().length === 0) {
54
+ return { ok: true, secrets: {} };
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ }
60
+ catch (error) {
61
+ return {
62
+ ok: false,
63
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
64
+ };
65
+ }
66
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
67
+ return {
68
+ ok: false,
69
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV} must be a JSON object of credential key to string value.`,
70
+ };
71
+ }
72
+ const secrets = {};
73
+ for (const [key, value] of Object.entries(parsed)) {
74
+ if (typeof value !== "string") {
75
+ return {
76
+ ok: false,
77
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV}.${key} must be a string (received ${typeof value}); the value is not echoed here.`,
78
+ };
79
+ }
80
+ secrets[key] = value;
81
+ }
82
+ return { ok: true, secrets };
83
+ }
84
+ /**
85
+ * Resolves the connection to attach for one local invocation.
86
+ *
87
+ * Three outcomes, and the middle one is the point of the change:
88
+ *
89
+ * - the provider needs no credential (`auth.mode` absent or `"none"`) →
90
+ * no connection, exactly as before;
91
+ * - the provider needs one and none is configured → `ok: false` with a
92
+ * message naming both ways to supply it. The caller surfaces that instead
93
+ * of letting the provider fail deep inside a handler on a `MISSING_SECRET`
94
+ * that reads like an upstream problem;
95
+ * - the provider needs one and it is configured → the gateway's envelope
96
+ * shape.
97
+ *
98
+ * CLI overrides win over the environment so a one-off run does not need the
99
+ * shell variable unset.
100
+ */
101
+ export function resolveLocalConnection(provider, options = {}) {
102
+ const fromEnv = parseEnvironmentSecrets((options.env ?? process.env)[LOCAL_CONNECTION_SECRETS_ENV]);
103
+ if (!fromEnv.ok)
104
+ return fromEnv;
105
+ const fromArgs = parseCredentialOverrides(options.overrides ?? []);
106
+ if (!fromArgs.ok)
107
+ return fromArgs;
108
+ const secrets = { ...fromEnv.secrets, ...fromArgs.secrets };
109
+ const mode = provider.auth?.mode;
110
+ if (!providerRequiresConnection(provider)) {
111
+ if (Object.keys(secrets).length > 0) {
112
+ return {
113
+ ok: false,
114
+ reason: `Provider "${provider.id}" declares auth.mode ${JSON.stringify(mode ?? "none")}, which takes no credential. Remove the supplied credential values.`,
115
+ };
116
+ }
117
+ return { ok: true, connection: undefined };
118
+ }
119
+ if (Object.keys(secrets).length === 0) {
120
+ return {
121
+ ok: false,
122
+ reason: missingLocalConnectionMessage(provider.id, mode ?? "none"),
123
+ };
124
+ }
125
+ return {
126
+ ok: true,
127
+ connection: {
128
+ id: LOCAL_CONNECTION_ID,
129
+ // `mode` is narrowed by providerRequiresConnection above.
130
+ mode: mode ?? "none",
131
+ secrets,
132
+ metadata: {},
133
+ externalRef: "",
134
+ },
135
+ };
136
+ }
137
+ /** The message a caller prints when a needed credential is not configured. */
138
+ export function missingLocalConnectionMessage(providerId, mode) {
139
+ const platformManaged = mode === "platform-managed";
140
+ return [
141
+ `Provider "${providerId}" declares auth.mode "${mode}", so the gateway injects a credential on every invocation and this command has none.`,
142
+ platformManaged
143
+ ? "The value is the platform-owned key an operator holds; it is not a provider secret and is not derivable from the checkout."
144
+ : "Supply the same credential the gateway would resolve for a connection.",
145
+ "Supply it with one of:",
146
+ " --credential <key>=<value> (repeatable)",
147
+ ` ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'`,
148
+ "The keys are the ones the provider passes to ctx.credential.get(...).",
149
+ "Pass --no-credential to run without one anyway (the provider will fail the way it does in production when the credential is unavailable).",
150
+ ].join("\n");
151
+ }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.65",
2
+ "version": "2.2.0-beta.67",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -0,0 +1,221 @@
1
+ import type { AuthMode, ProviderDefinition } from "../types.js";
2
+
3
+ /**
4
+ * The connection the local CLIs stand in for the gateway with.
5
+ *
6
+ * At invocation time the gateway resolves the credential a provider needs and
7
+ * attaches it to the request envelope as
8
+ * `connection: { id, mode, secrets, metadata }`; `serve` then builds
9
+ * `ctx.credential` from `connection.secrets` and nothing else. For
10
+ * `auth.mode: "platform-managed"` the secrets are the platform-owned key the
11
+ * caller never sees — which is why a probe reaches one only by going back
12
+ * through the gateway (`ctx.gateway.execute(...)` in a health case's
13
+ * `prepareInput`), and why nothing that runs outside the gateway had any way
14
+ * to supply it.
15
+ *
16
+ * `apifuse record` hard-coded `credential: { mode: "none" }`, so
17
+ * `ctx.credential.get(...)` returned `undefined` for every platform-managed
18
+ * provider and the run died on that provider's own `MISSING_SECRET`. The same
19
+ * hole made `submit-check --smoke` POST an envelope with no `connection` at
20
+ * all. A large share of the fleet was unrecordable and unsmokeable, and it
21
+ * read as "no live upstream success" rather than as a missing capability.
22
+ *
23
+ * This module is the local resolver for that connection. It does not invent a
24
+ * second credential path: it produces the exact shape the gateway injects, so
25
+ * the provider code under test takes the production branch.
26
+ */
27
+ export interface LocalConnection {
28
+ readonly id: string;
29
+ readonly mode: AuthMode;
30
+ readonly secrets: Record<string, string>;
31
+ readonly metadata: Record<string, never>;
32
+ /**
33
+ * Empty, exactly as the gateway leaves it when it injects a platform
34
+ * credential. `OperationConnectionSchema` requires the field, so omitting
35
+ * it would make every smoke envelope fail request validation before the
36
+ * provider ran.
37
+ */
38
+ readonly externalRef: string;
39
+ }
40
+
41
+ /**
42
+ * JSON object of `connection.secrets`, keyed exactly as the provider reads
43
+ * them through `ctx.credential.get(...)`.
44
+ *
45
+ * A single JSON value rather than one variable per key on purpose:
46
+ * platform-managed providers must not declare `credential.keys` (the SDK lint
47
+ * rejects it), so the key names are known only to the provider's own source.
48
+ * There is no derivation from an env name that would be right for every
49
+ * provider, and guessing one would be a second, lossy contract next to the
50
+ * gateway's.
51
+ */
52
+ export const LOCAL_CONNECTION_SECRETS_ENV = "APIFUSE__LOCAL_CONNECTION__SECRETS";
53
+
54
+ /** Stable local connection id, mirroring the CLIs' other `local-*` scopes. */
55
+ export const LOCAL_CONNECTION_ID = "local-connection";
56
+
57
+ /** Auth modes whose providers read a credential the gateway supplies. */
58
+ export function providerRequiresConnection(provider: {
59
+ readonly auth?: { readonly mode?: AuthMode } | undefined;
60
+ }): boolean {
61
+ const mode = provider.auth?.mode;
62
+ return mode !== undefined && mode !== "none";
63
+ }
64
+
65
+ export type CredentialOverrideParse =
66
+ | { readonly ok: true; readonly secrets: Record<string, string> }
67
+ | { readonly ok: false; readonly reason: string };
68
+
69
+ /**
70
+ * Parses repeated `--credential key=value` arguments.
71
+ *
72
+ * Splits on the first `=` only: a base64 or URL-shaped secret routinely
73
+ * contains more.
74
+ */
75
+ export function parseCredentialOverrides(values: readonly string[]): CredentialOverrideParse {
76
+ const secrets: Record<string, string> = {};
77
+ for (const [index, value] of values.entries()) {
78
+ // The offending argument is never echoed. `--credential =secret` and
79
+ // `--credential secret` are both malformed *and* carry the credential, so
80
+ // quoting the input to be helpful would print it to stderr — before any
81
+ // redaction context exists, so no caller could scrub it.
82
+ const separator = value.indexOf("=");
83
+ if (separator <= 0) {
84
+ return {
85
+ ok: false,
86
+ reason: `--credential #${index + 1} is not of the form key=value; its value is not echoed here because it may be the credential.`,
87
+ };
88
+ }
89
+ const key = value.slice(0, separator).trim();
90
+ if (key.length === 0) {
91
+ return {
92
+ ok: false,
93
+ reason: `--credential #${index + 1} has an empty key; its value is not echoed here because it may be the credential.`,
94
+ };
95
+ }
96
+ secrets[key] = value.slice(separator + 1);
97
+ }
98
+ return { ok: true, secrets };
99
+ }
100
+
101
+ /** Reads {@link LOCAL_CONNECTION_SECRETS_ENV} into a secrets map. */
102
+ export function parseEnvironmentSecrets(raw: string | undefined): CredentialOverrideParse {
103
+ if (raw === undefined || raw.trim().length === 0) {
104
+ return { ok: true, secrets: {} };
105
+ }
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ } catch (error) {
110
+ return {
111
+ ok: false,
112
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
113
+ };
114
+ }
115
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
116
+ return {
117
+ ok: false,
118
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV} must be a JSON object of credential key to string value.`,
119
+ };
120
+ }
121
+ const secrets: Record<string, string> = {};
122
+ for (const [key, value] of Object.entries(parsed)) {
123
+ if (typeof value !== "string") {
124
+ return {
125
+ ok: false,
126
+ reason: `${LOCAL_CONNECTION_SECRETS_ENV}.${key} must be a string (received ${typeof value}); the value is not echoed here.`,
127
+ };
128
+ }
129
+ secrets[key] = value;
130
+ }
131
+ return { ok: true, secrets };
132
+ }
133
+
134
+ export interface ResolveLocalConnectionOptions {
135
+ /** Repeated `--credential key=value` arguments, in order. */
136
+ readonly overrides?: readonly string[];
137
+ /** Environment to read {@link LOCAL_CONNECTION_SECRETS_ENV} from. */
138
+ readonly env?: Readonly<Record<string, string | undefined>>;
139
+ }
140
+
141
+ export type LocalConnectionResolution =
142
+ | { readonly ok: true; readonly connection: LocalConnection | undefined }
143
+ | { readonly ok: false; readonly reason: string };
144
+
145
+ /**
146
+ * Resolves the connection to attach for one local invocation.
147
+ *
148
+ * Three outcomes, and the middle one is the point of the change:
149
+ *
150
+ * - the provider needs no credential (`auth.mode` absent or `"none"`) →
151
+ * no connection, exactly as before;
152
+ * - the provider needs one and none is configured → `ok: false` with a
153
+ * message naming both ways to supply it. The caller surfaces that instead
154
+ * of letting the provider fail deep inside a handler on a `MISSING_SECRET`
155
+ * that reads like an upstream problem;
156
+ * - the provider needs one and it is configured → the gateway's envelope
157
+ * shape.
158
+ *
159
+ * CLI overrides win over the environment so a one-off run does not need the
160
+ * shell variable unset.
161
+ */
162
+ export function resolveLocalConnection(
163
+ provider: Pick<ProviderDefinition, "id"> & { auth?: { mode?: AuthMode } },
164
+ options: ResolveLocalConnectionOptions = {},
165
+ ): LocalConnectionResolution {
166
+ const fromEnv = parseEnvironmentSecrets(
167
+ (options.env ?? process.env)[LOCAL_CONNECTION_SECRETS_ENV],
168
+ );
169
+ if (!fromEnv.ok) return fromEnv;
170
+
171
+ const fromArgs = parseCredentialOverrides(options.overrides ?? []);
172
+ if (!fromArgs.ok) return fromArgs;
173
+
174
+ const secrets = { ...fromEnv.secrets, ...fromArgs.secrets };
175
+ const mode = provider.auth?.mode;
176
+
177
+ if (!providerRequiresConnection(provider)) {
178
+ if (Object.keys(secrets).length > 0) {
179
+ return {
180
+ ok: false,
181
+ reason: `Provider "${provider.id}" declares auth.mode ${JSON.stringify(mode ?? "none")}, which takes no credential. Remove the supplied credential values.`,
182
+ };
183
+ }
184
+ return { ok: true, connection: undefined };
185
+ }
186
+
187
+ if (Object.keys(secrets).length === 0) {
188
+ return {
189
+ ok: false,
190
+ reason: missingLocalConnectionMessage(provider.id, mode ?? "none"),
191
+ };
192
+ }
193
+
194
+ return {
195
+ ok: true,
196
+ connection: {
197
+ id: LOCAL_CONNECTION_ID,
198
+ // `mode` is narrowed by providerRequiresConnection above.
199
+ mode: mode ?? "none",
200
+ secrets,
201
+ metadata: {},
202
+ externalRef: "",
203
+ },
204
+ };
205
+ }
206
+
207
+ /** The message a caller prints when a needed credential is not configured. */
208
+ export function missingLocalConnectionMessage(providerId: string, mode: AuthMode): string {
209
+ const platformManaged = mode === "platform-managed";
210
+ return [
211
+ `Provider "${providerId}" declares auth.mode "${mode}", so the gateway injects a credential on every invocation and this command has none.`,
212
+ platformManaged
213
+ ? "The value is the platform-owned key an operator holds; it is not a provider secret and is not derivable from the checkout."
214
+ : "Supply the same credential the gateway would resolve for a connection.",
215
+ "Supply it with one of:",
216
+ " --credential <key>=<value> (repeatable)",
217
+ ` ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'`,
218
+ "The keys are the ones the provider passes to ctx.credential.get(...).",
219
+ "Pass --no-credential to run without one anyway (the provider will fail the way it does in production when the credential is unavailable).",
220
+ ].join("\n");
221
+ }