@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +28 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +207 -31
- package/bin/apifuse-submit-check.ts +273 -74
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +493 -2
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +2 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +43 -29
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/lint.ts +541 -1
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +2 -1
- package/src/runtime/provider.ts +15 -0
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +56 -30
- package/src/server/state-backend-report.ts +185 -0
|
@@ -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);
|
|
@@ -4066,18 +4076,37 @@ type MaskCommentsAndStringsOptions = {
|
|
|
4066
4076
|
blankPropertyKeys?: boolean;
|
|
4067
4077
|
};
|
|
4068
4078
|
|
|
4079
|
+
// Which literal bodies are blanked alongside comment bodies. "all" blanks
|
|
4080
|
+
// every string/template/regex body; "values" does the same but keeps quoted
|
|
4081
|
+
// property-key text (only its structural characters are blanked); "none"
|
|
4082
|
+
// leaves every literal and all code byte-identical, so only comments go.
|
|
4083
|
+
type LiteralMaskMode = "all" | "values" | "none";
|
|
4084
|
+
|
|
4069
4085
|
export function maskCommentsAndStrings(
|
|
4070
4086
|
source: string,
|
|
4071
4087
|
fileName = "provider.ts",
|
|
4072
4088
|
options: MaskCommentsAndStringsOptions = {},
|
|
4073
4089
|
): string {
|
|
4074
|
-
|
|
4075
|
-
|
|
4090
|
+
return maskSource(source, fileName, options.blankPropertyKeys === true ? "all" : "values");
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// Blanks comment bodies only, from the same TypeScript-lexer comment ranges
|
|
4094
|
+
// maskCommentsAndStrings uses: a `//` inside a string, template, or regex is
|
|
4095
|
+
// not a comment, and a block comment spanning lines is blanked whole. Line
|
|
4096
|
+
// terminators survive, so line N of the result is line N of the source.
|
|
4097
|
+
// Throws like maskCommentsAndStrings when the source cannot be parsed.
|
|
4098
|
+
export function maskComments(source: string, fileName = "provider.ts"): string {
|
|
4099
|
+
return maskSource(source, fileName, "none");
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
function maskSource(source: string, fileName: string, literals: LiteralMaskMode): string {
|
|
4103
|
+
const { scriptKind } = maskScriptKind(fileName);
|
|
4104
|
+
const cacheKey = `${literals}\0${scriptKind}\0${source}`;
|
|
4076
4105
|
const cached = maskCache.get(cacheKey);
|
|
4077
4106
|
if (cached !== undefined) {
|
|
4078
4107
|
return cached;
|
|
4079
4108
|
}
|
|
4080
|
-
const masked = computeMaskedSource(source, fileName,
|
|
4109
|
+
const masked = computeMaskedSource(source, fileName, literals);
|
|
4081
4110
|
if (maskCache.size >= MASK_CACHE_LIMIT) {
|
|
4082
4111
|
const oldest = maskCache.keys().next();
|
|
4083
4112
|
if (!oldest.done) {
|
|
@@ -4088,17 +4117,34 @@ export function maskCommentsAndStrings(
|
|
|
4088
4117
|
return masked;
|
|
4089
4118
|
}
|
|
4090
4119
|
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4120
|
+
// The script kind a source is lexed as, from its file name, and the synthetic
|
|
4121
|
+
// implementation file name transpileModule parses it under. Declaration files
|
|
4122
|
+
// trigger an internal TypeScript Debug Failure when passed to transpileModule,
|
|
4123
|
+
// so `.d.ts` (like every other non-JS/JSX/TSX name) parses as `provider.ts`.
|
|
4124
|
+
function maskScriptKind(fileName: string): {
|
|
4125
|
+
scriptKind: TS.ScriptKind;
|
|
4126
|
+
syntheticFileName: string;
|
|
4127
|
+
} {
|
|
4128
|
+
if (fileName.endsWith(".tsx")) {
|
|
4129
|
+
return { scriptKind: ts.ScriptKind.TSX, syntheticFileName: "provider.tsx" };
|
|
4130
|
+
}
|
|
4131
|
+
if (fileName.endsWith(".jsx")) {
|
|
4132
|
+
return { scriptKind: ts.ScriptKind.JSX, syntheticFileName: "provider.jsx" };
|
|
4133
|
+
}
|
|
4134
|
+
if (/\.[cm]?js$/.test(fileName)) {
|
|
4135
|
+
return { scriptKind: ts.ScriptKind.JS, syntheticFileName: "provider.js" };
|
|
4136
|
+
}
|
|
4137
|
+
return { scriptKind: ts.ScriptKind.TS, syntheticFileName: "provider.ts" };
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
function computeMaskedSource(source: string, fileName: string, literals: LiteralMaskMode): string {
|
|
4141
|
+
const blankPropertyKeys = literals === "all";
|
|
4142
|
+
const { scriptKind, syntheticFileName } = maskScriptKind(fileName);
|
|
4096
4143
|
const transpiled = ts.transpileModule(source, {
|
|
4097
|
-
//
|
|
4098
|
-
//
|
|
4099
|
-
//
|
|
4100
|
-
|
|
4101
|
-
fileName: "provider.ts",
|
|
4144
|
+
// Parsing is all we need here: parse under the synthetic implementation
|
|
4145
|
+
// filename while retaining the real filename for source mapping and
|
|
4146
|
+
// sanitized diagnostics below.
|
|
4147
|
+
fileName: syntheticFileName,
|
|
4102
4148
|
reportDiagnostics: true,
|
|
4103
4149
|
compilerOptions: { target: ts.ScriptTarget.Latest },
|
|
4104
4150
|
});
|
|
@@ -4107,7 +4153,7 @@ function computeMaskedSource(
|
|
|
4107
4153
|
source,
|
|
4108
4154
|
ts.ScriptTarget.Latest,
|
|
4109
4155
|
true,
|
|
4110
|
-
|
|
4156
|
+
scriptKind,
|
|
4111
4157
|
);
|
|
4112
4158
|
const parseDiagnostic = transpiled.diagnostics?.[0];
|
|
4113
4159
|
if (parseDiagnostic) {
|
|
@@ -4125,10 +4171,7 @@ function computeMaskedSource(
|
|
|
4125
4171
|
// by LF, CRLF, or lone CR) and every line terminator, so masked
|
|
4126
4172
|
// string bodies remain syntactically valid and the mask output can
|
|
4127
4173
|
// be re-parsed (mask(mask(x)) === mask(x)).
|
|
4128
|
-
if (
|
|
4129
|
-
source[index] === "\\" &&
|
|
4130
|
-
(source[index + 1] === "\n" || source[index + 1] === "\r")
|
|
4131
|
-
) {
|
|
4174
|
+
if (source[index] === "\\" && (source[index + 1] === "\n" || source[index + 1] === "\r")) {
|
|
4132
4175
|
continue;
|
|
4133
4176
|
}
|
|
4134
4177
|
if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
|
|
@@ -4141,17 +4184,13 @@ function computeMaskedSource(
|
|
|
4141
4184
|
}
|
|
4142
4185
|
};
|
|
4143
4186
|
|
|
4144
|
-
const
|
|
4145
|
-
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4146
|
-
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4147
|
-
|
|
4187
|
+
const maskLiteralBody = (node: TS.Node): void => {
|
|
4148
4188
|
const start = node.getStart(sourceFile);
|
|
4149
4189
|
if (ts.isStringLiteral(node)) {
|
|
4150
4190
|
// Preserve quoted property keys ("response": ...) by default so range/key
|
|
4151
4191
|
// scanners can still match them. Line scanners opt into blanking key bodies
|
|
4152
4192
|
// as well, preventing key text from looking like executable source.
|
|
4153
|
-
const isQuotedPropertyKey =
|
|
4154
|
-
ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4193
|
+
const isQuotedPropertyKey = ts.isPropertyAssignment(node.parent) && node.parent.name === node;
|
|
4155
4194
|
if (blankPropertyKeys || !isQuotedPropertyKey) {
|
|
4156
4195
|
maskRange(start + 1, node.end - 1);
|
|
4157
4196
|
} else {
|
|
@@ -4182,7 +4221,12 @@ function computeMaskedSource(
|
|
|
4182
4221
|
} else if (node.kind === ts.SyntaxKind.TemplateTail) {
|
|
4183
4222
|
maskRange(start + 1, node.end - 1);
|
|
4184
4223
|
}
|
|
4224
|
+
};
|
|
4185
4225
|
|
|
4226
|
+
const visit = (node: TS.Node): void => {
|
|
4227
|
+
addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
|
|
4228
|
+
addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
|
|
4229
|
+
if (literals !== "none") maskLiteralBody(node);
|
|
4186
4230
|
for (const child of node.getChildren(sourceFile)) visit(child);
|
|
4187
4231
|
};
|
|
4188
4232
|
|
|
@@ -5026,7 +5070,7 @@ function isAstNode(value: unknown): value is acorn.AnyNode {
|
|
|
5026
5070
|
);
|
|
5027
5071
|
}
|
|
5028
5072
|
|
|
5029
|
-
function scoreSmoke(
|
|
5073
|
+
export function scoreSmoke(
|
|
5030
5074
|
smokeResult: SmokeResult | undefined,
|
|
5031
5075
|
smokeNote: string | undefined,
|
|
5032
5076
|
): SubmitCheck {
|
|
@@ -5048,8 +5092,22 @@ function scoreSmoke(
|
|
|
5048
5092
|
};
|
|
5049
5093
|
}
|
|
5050
5094
|
|
|
5095
|
+
const credential = smokeResult.credential;
|
|
5096
|
+
// An invalid configuration is reported even for a provider that needs no
|
|
5097
|
+
// credential: the operator set something, and silence would read as accepted.
|
|
5098
|
+
const credentialEvidence =
|
|
5099
|
+
credential?.required === true || credential?.error !== undefined
|
|
5100
|
+
? [
|
|
5101
|
+
credential.supplied
|
|
5102
|
+
? `credential: supplied for auth.mode "${credential.mode}"`
|
|
5103
|
+
: credential.error
|
|
5104
|
+
? `credential: CONFIGURED BUT INVALID — ${credential.error}`
|
|
5105
|
+
: `credential: NOT supplied; auth.mode "${credential.mode}" needs one the gateway injects at invocation time, so every credentialed operation failed before reaching the upstream`,
|
|
5106
|
+
]
|
|
5107
|
+
: [];
|
|
5051
5108
|
const evidence = [
|
|
5052
5109
|
`/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
|
|
5110
|
+
...credentialEvidence,
|
|
5053
5111
|
...smokeResult.operations.map(
|
|
5054
5112
|
(outcome) =>
|
|
5055
5113
|
`${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
|
|
@@ -5073,6 +5131,31 @@ function scoreSmoke(
|
|
|
5073
5131
|
};
|
|
5074
5132
|
}
|
|
5075
5133
|
|
|
5134
|
+
// Checked BEFORE the success branch. A provider that needs a
|
|
5135
|
+
// gateway-injected credential and did not get one never exercised its
|
|
5136
|
+
// credentialed path, so a schema-valid success from some other operation --
|
|
5137
|
+
// an unauthenticated one, or a fixture-shaped response -- must not buy full
|
|
5138
|
+
// smoke points. Saying "verified" would credit the run with evidence it does
|
|
5139
|
+
// not have.
|
|
5140
|
+
if (credential?.required === true && !credential.supplied) {
|
|
5141
|
+
return {
|
|
5142
|
+
id: "local-smoke",
|
|
5143
|
+
category: "smoke",
|
|
5144
|
+
level: "warn",
|
|
5145
|
+
status: "warn",
|
|
5146
|
+
points: 7,
|
|
5147
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
5148
|
+
message: credential.error
|
|
5149
|
+
? `Runtime path was verified, but the configured local credential could not be used, so the provider's credentialed operations never reached the upstream.`
|
|
5150
|
+
: `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.`,
|
|
5151
|
+
remediation: credential.error
|
|
5152
|
+
? `Fix the local credential configuration and rerun \`bun run submit-check -- --smoke\`: ${credential.error}`
|
|
5153
|
+
: `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.`,
|
|
5154
|
+
evidence,
|
|
5155
|
+
details: smokeResult,
|
|
5156
|
+
};
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5076
5159
|
const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
|
|
5077
5160
|
if (successes.length > 0) {
|
|
5078
5161
|
return {
|
|
@@ -5143,6 +5226,12 @@ export async function runSubmitCheckSmoke(
|
|
|
5143
5226
|
operations: [],
|
|
5144
5227
|
};
|
|
5145
5228
|
}
|
|
5229
|
+
// The gateway attaches a resolved credential to every invocation of a
|
|
5230
|
+
// provider whose auth.mode is not "none". Smoke POSTed an envelope with
|
|
5231
|
+
// no `connection` at all, so every platform-managed provider answered
|
|
5232
|
+
// MISSING_SECRET and scored as "runtime verified, no live success" —
|
|
5233
|
+
// a capability gap wearing the costume of an ordinary upstream miss.
|
|
5234
|
+
const connection = resolveLocalConnection(loadedProvider);
|
|
5146
5235
|
const operations: SmokeOperationOutcome[] = [];
|
|
5147
5236
|
for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
|
|
5148
5237
|
operations.push(
|
|
@@ -5150,10 +5239,21 @@ export async function runSubmitCheckSmoke(
|
|
|
5150
5239
|
requestId: `req_submit_check_smoke_${operationId}`,
|
|
5151
5240
|
input: operation.fixtures?.request ?? {},
|
|
5152
5241
|
headers: {},
|
|
5242
|
+
...(connection.ok && connection.connection ? { connection: connection.connection } : {}),
|
|
5153
5243
|
}),
|
|
5154
5244
|
);
|
|
5155
5245
|
}
|
|
5156
|
-
return {
|
|
5246
|
+
return {
|
|
5247
|
+
measured: true,
|
|
5248
|
+
healthOk: true,
|
|
5249
|
+
operations,
|
|
5250
|
+
credential: {
|
|
5251
|
+
required: providerRequiresConnection(loadedProvider),
|
|
5252
|
+
supplied: connection.ok && connection.connection !== undefined,
|
|
5253
|
+
mode: loadedProvider.auth?.mode ?? "none",
|
|
5254
|
+
...(connection.ok ? {} : { error: connection.reason }),
|
|
5255
|
+
},
|
|
5256
|
+
};
|
|
5157
5257
|
} finally {
|
|
5158
5258
|
await stopSmokeServer(server);
|
|
5159
5259
|
}
|
|
@@ -5541,18 +5641,18 @@ function findEntropySecretFindings(providerRoot: string, providerId: string): Se
|
|
|
5541
5641
|
if (isEntropySecretExcludedPath(relativePath)) continue;
|
|
5542
5642
|
const content = readFileSync(filePath, "utf8");
|
|
5543
5643
|
const lines = content.split(/\r?\n/);
|
|
5544
|
-
for (
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5644
|
+
for (const candidate of extractStringLiteralCandidates(content, relativePath)) {
|
|
5645
|
+
// Context is the whole source line, comment tail included: a
|
|
5646
|
+
// comment that names the value is evidence about it, even though
|
|
5647
|
+
// comment text itself is never a candidate.
|
|
5648
|
+
const finding = classifyEntropyCandidate({
|
|
5649
|
+
value: candidate.value,
|
|
5650
|
+
line: lines[candidate.lineIndex] ?? "",
|
|
5651
|
+
file: relativePath,
|
|
5652
|
+
lineNumber: candidate.lineIndex + 1,
|
|
5653
|
+
providerId,
|
|
5654
|
+
});
|
|
5655
|
+
if (finding) findings.push(finding);
|
|
5556
5656
|
}
|
|
5557
5657
|
}
|
|
5558
5658
|
return findings;
|
|
@@ -5568,7 +5668,62 @@ function isEntropySecretExcludedPath(relativePath: string): boolean {
|
|
|
5568
5668
|
);
|
|
5569
5669
|
}
|
|
5570
5670
|
|
|
5571
|
-
export
|
|
5671
|
+
export type StringLiteralCandidate = {
|
|
5672
|
+
value: string;
|
|
5673
|
+
// 0-based index of the source line the literal sits on.
|
|
5674
|
+
lineIndex: number;
|
|
5675
|
+
};
|
|
5676
|
+
|
|
5677
|
+
// Every string-literal candidate (a quoted body of at least
|
|
5678
|
+
// ENTROPY_CANDIDATE_MIN_LENGTH characters) in one provider source file.
|
|
5679
|
+
// Candidates come from code only. Comments are prose, not values, so each
|
|
5680
|
+
// file's own comment syntax is removed before a quote is read:
|
|
5681
|
+
// - JS/TS sources: comment trivia is blanked with the TypeScript lexer
|
|
5682
|
+
// (maskComments, the comment ranges the other source lints skip), so `//`
|
|
5683
|
+
// inside a string or regex is not a comment and a `/* … */` block spanning
|
|
5684
|
+
// lines is skipped whole. A backtick in code opens a template literal — a
|
|
5685
|
+
// string value — so it stays a quote form.
|
|
5686
|
+
// - Shell sources (.sh/.bash, Dockerfile, entrypoint): `#` starts a comment
|
|
5687
|
+
// when it begins a word outside quotes, and a backtick is command
|
|
5688
|
+
// substitution (code), not a string delimiter.
|
|
5689
|
+
// Identifiers are therefore never candidates by construction: a bare name is
|
|
5690
|
+
// not a string literal, and a backticked name in a comment is comment text.
|
|
5691
|
+
// A JS/TS source the lexer cannot parse keeps the unmasked scan (every quote
|
|
5692
|
+
// pair is a candidate): when comments cannot be located the scan may
|
|
5693
|
+
// over-report, never under-report.
|
|
5694
|
+
export function extractStringLiteralCandidates(
|
|
5695
|
+
content: string,
|
|
5696
|
+
relativePath: string,
|
|
5697
|
+
): StringLiteralCandidate[] {
|
|
5698
|
+
const shell = !isJavaScriptFamilySource(relativePath);
|
|
5699
|
+
const codeLines = (shell ? content : maskCommentsForSecretScan(content, relativePath)).split(
|
|
5700
|
+
/\r?\n/,
|
|
5701
|
+
);
|
|
5702
|
+
const candidates: StringLiteralCandidate[] = [];
|
|
5703
|
+
for (let lineIndex = 0; lineIndex < codeLines.length; lineIndex += 1) {
|
|
5704
|
+
const line = codeLines[lineIndex] ?? "";
|
|
5705
|
+
const values = shell ? extractShellLineStrings(line) : extractQuotedLineStrings(line);
|
|
5706
|
+
for (const value of values) candidates.push({ value, lineIndex });
|
|
5707
|
+
}
|
|
5708
|
+
return candidates;
|
|
5709
|
+
}
|
|
5710
|
+
|
|
5711
|
+
function isJavaScriptFamilySource(relativePath: string): boolean {
|
|
5712
|
+
return /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(relativePath);
|
|
5713
|
+
}
|
|
5714
|
+
|
|
5715
|
+
function maskCommentsForSecretScan(content: string, relativePath: string): string {
|
|
5716
|
+
try {
|
|
5717
|
+
return maskComments(content, relativePath);
|
|
5718
|
+
} catch {
|
|
5719
|
+
// Unparseable: scan the source unmasked rather than skip it.
|
|
5720
|
+
return content;
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
// Quoted bodies on one comment-free JS/TS line (`"`, `'`, and template
|
|
5725
|
+
// backticks), with backslash escapes.
|
|
5726
|
+
function extractQuotedLineStrings(line: string): string[] {
|
|
5572
5727
|
const candidates: string[] = [];
|
|
5573
5728
|
for (let index = 0; index < line.length; index += 1) {
|
|
5574
5729
|
const quote = line[index];
|
|
@@ -5595,6 +5750,51 @@ export function extractStringLiteralCandidates(line: string): string[] {
|
|
|
5595
5750
|
return candidates;
|
|
5596
5751
|
}
|
|
5597
5752
|
|
|
5753
|
+
// Characters after which a `#` begins a new shell word (blanks and the
|
|
5754
|
+
// metacharacters `;&|()<>`).
|
|
5755
|
+
const SHELL_WORD_BOUNDARY = /[\s;&|()<>]/;
|
|
5756
|
+
|
|
5757
|
+
// Quoted bodies on one shell line. Single quotes take no escapes; double
|
|
5758
|
+
// quotes and unquoted text honor backslash escapes. A `#` that begins a word
|
|
5759
|
+
// outside quotes ends the line's code (the rest is a comment). A word begins
|
|
5760
|
+
// at the line start or after an UNESCAPED boundary character, so `${#var}`,
|
|
5761
|
+
// `$#`, `"a"#b`, and `word\ #` (escaped blank) keep `#` inside the word.
|
|
5762
|
+
// Backticks are command substitution, so their text is lexed as code. A
|
|
5763
|
+
// quote left open on the line is read as a literal character, as the
|
|
5764
|
+
// line-based scan always has.
|
|
5765
|
+
function extractShellLineStrings(line: string): string[] {
|
|
5766
|
+
const candidates: string[] = [];
|
|
5767
|
+
let index = 0;
|
|
5768
|
+
let atWordStart = true;
|
|
5769
|
+
while (index < line.length) {
|
|
5770
|
+
const char = line[index];
|
|
5771
|
+
if (char === "\\") {
|
|
5772
|
+
index += 2;
|
|
5773
|
+
atWordStart = false;
|
|
5774
|
+
continue;
|
|
5775
|
+
}
|
|
5776
|
+
if (char === "#" && atWordStart) break;
|
|
5777
|
+
if (char === '"' || char === "'") {
|
|
5778
|
+
const contentStart = index + 1;
|
|
5779
|
+
let cursor = contentStart;
|
|
5780
|
+
while (cursor < line.length && line[cursor] !== char) {
|
|
5781
|
+
cursor += char === '"' && line[cursor] === "\\" ? 2 : 1;
|
|
5782
|
+
}
|
|
5783
|
+
if (cursor < line.length) {
|
|
5784
|
+
if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
|
|
5785
|
+
candidates.push(line.slice(contentStart, cursor));
|
|
5786
|
+
}
|
|
5787
|
+
index = cursor + 1;
|
|
5788
|
+
atWordStart = false;
|
|
5789
|
+
continue;
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
atWordStart = SHELL_WORD_BOUNDARY.test(char ?? "");
|
|
5793
|
+
index += 1;
|
|
5794
|
+
}
|
|
5795
|
+
return candidates;
|
|
5796
|
+
}
|
|
5797
|
+
|
|
5598
5798
|
function classifyEntropyCandidate(input: {
|
|
5599
5799
|
value: string;
|
|
5600
5800
|
line: string;
|
|
@@ -5699,7 +5899,7 @@ function literalContainerKey(literal: LineStringLiteral): string {
|
|
|
5699
5899
|
// other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
|
|
5700
5900
|
// elements, call arguments, and assignment right-hand sides, even when a
|
|
5701
5901
|
// ternary's ":" happens to follow them. Uses the same quote/escape walking as
|
|
5702
|
-
//
|
|
5902
|
+
// extractQuotedLineStrings.
|
|
5703
5903
|
function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
|
|
5704
5904
|
const literals: LineStringLiteral[] = [];
|
|
5705
5905
|
const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
|
|
@@ -5994,8 +6194,7 @@ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
|
|
|
5994
6194
|
const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
|
|
5995
6195
|
const relativePath = relative(providerRoot, resolve(pathCandidate));
|
|
5996
6196
|
const insideProvider =
|
|
5997
|
-
relativePath === "" ||
|
|
5998
|
-
(relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
6197
|
+
relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
5999
6198
|
return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
|
|
6000
6199
|
},
|
|
6001
6200
|
);
|
|
@@ -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;
|