@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 +8 -0
- package/bin/apifuse-check.ts +55 -1
- package/bin/apifuse-record.ts +126 -10
- package/bin/apifuse-submit-check.ts +104 -47
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
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.
|
package/bin/apifuse-check.ts
CHANGED
|
@@ -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: [
|
|
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 {
|
package/bin/apifuse-record.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
2138
|
-
)
|
|
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 {
|
|
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
|
@@ -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
|
+
}
|