@apifuse/provider-sdk 2.1.0-beta.1 → 2.1.0-beta.3
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 +27 -0
- package/CHANGELOG.md +14 -0
- package/README.md +83 -1
- package/SUBMISSION.md +86 -0
- package/bin/apifuse-check.ts +30 -2
- package/bin/apifuse-dev.ts +30 -6
- package/bin/apifuse-pack-check.ts +58 -0
- package/bin/apifuse-pack-smoke.ts +181 -1
- package/bin/apifuse-submit-check.ts +880 -0
- package/package.json +8 -7
- package/src/cli/commands.ts +23 -0
- package/src/cli/create.ts +9 -4
- package/src/cli/templates/provider/README.md.tpl +85 -1
- package/src/cli/templates/provider/index.ts.tpl +0 -1
- package/src/define.ts +92 -1
- package/src/index.ts +3 -0
- package/src/server/serve.ts +12 -3
- package/src/types.ts +20 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
import packageJson from "../package.json";
|
|
9
|
+
import type { ProviderDefinition } from "../src";
|
|
10
|
+
import { type CheckResult, runChecks } from "./apifuse-check";
|
|
11
|
+
|
|
12
|
+
const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
|
|
13
|
+
const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
|
|
14
|
+
type BountyTier = (typeof TIERS)[number];
|
|
15
|
+
|
|
16
|
+
type CheckLevel = "blocker" | "warn" | "info";
|
|
17
|
+
type CheckStatus = "pass" | "fail" | "warn" | "not_applicable";
|
|
18
|
+
type Verdict = "ready" | "reviewable_with_warnings" | "blocked";
|
|
19
|
+
|
|
20
|
+
export type SubmitCheck = {
|
|
21
|
+
id: string;
|
|
22
|
+
category: string;
|
|
23
|
+
level: CheckLevel;
|
|
24
|
+
status: CheckStatus;
|
|
25
|
+
points: number;
|
|
26
|
+
maxPoints: number;
|
|
27
|
+
message: string;
|
|
28
|
+
remediation?: string;
|
|
29
|
+
evidence?: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SubmitCheckReport = {
|
|
33
|
+
schemaVersion: 1;
|
|
34
|
+
generatedAt: string;
|
|
35
|
+
provider: {
|
|
36
|
+
id: string;
|
|
37
|
+
version: string;
|
|
38
|
+
runtime: string;
|
|
39
|
+
authMode: string;
|
|
40
|
+
sdkVersion: string;
|
|
41
|
+
tier?: BountyTier;
|
|
42
|
+
};
|
|
43
|
+
score: {
|
|
44
|
+
total: number;
|
|
45
|
+
max: 100;
|
|
46
|
+
verdict: Verdict;
|
|
47
|
+
};
|
|
48
|
+
summary: {
|
|
49
|
+
blockers: number;
|
|
50
|
+
warnings: number;
|
|
51
|
+
passed: number;
|
|
52
|
+
};
|
|
53
|
+
checks: SubmitCheck[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type CliArgs = {
|
|
57
|
+
isJson: boolean;
|
|
58
|
+
markdownPath?: string;
|
|
59
|
+
providerPath?: string;
|
|
60
|
+
smokeNote?: string;
|
|
61
|
+
tier?: BountyTier;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type SecretFinding = {
|
|
65
|
+
label: string;
|
|
66
|
+
file: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const CATEGORY_MAX_POINTS = {
|
|
70
|
+
definition: 15,
|
|
71
|
+
operations: 15,
|
|
72
|
+
fixtures: 15,
|
|
73
|
+
health: 15,
|
|
74
|
+
smoke: 10,
|
|
75
|
+
auth: 10,
|
|
76
|
+
security: 10,
|
|
77
|
+
docs: 10,
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke-note <text>]
|
|
81
|
+
Alias: apifuse bounty-check [path]
|
|
82
|
+
Default: apifuse submit-check .`;
|
|
83
|
+
|
|
84
|
+
export async function main() {
|
|
85
|
+
try {
|
|
86
|
+
const args = parseArgs(normalizeArgs(process.argv.slice(2)));
|
|
87
|
+
|
|
88
|
+
if (args.isJson && process.argv.includes("--help")) {
|
|
89
|
+
console.log(JSON.stringify({ help: HELP_TEXT }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const providerRoot = resolveProviderRoot(args.providerPath ?? ".");
|
|
94
|
+
const report = await buildSubmitCheckReport(providerRoot, args);
|
|
95
|
+
|
|
96
|
+
if (args.markdownPath) {
|
|
97
|
+
await writeFile(
|
|
98
|
+
resolve(process.cwd(), args.markdownPath),
|
|
99
|
+
renderMarkdown(report),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (args.isJson) {
|
|
104
|
+
console.log(JSON.stringify(report, null, 2));
|
|
105
|
+
} else {
|
|
106
|
+
console.log(renderText(report));
|
|
107
|
+
if (args.markdownPath) {
|
|
108
|
+
console.log(`\nMarkdown report: ${args.markdownPath}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (report.score.verdict === "blocked") {
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeArgs(argv: string[]): string[] {
|
|
122
|
+
const [command, ...rest] = argv;
|
|
123
|
+
return command === "submit-check" || command === "bounty-check" ? rest : argv;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
127
|
+
const args: CliArgs = { isJson: false };
|
|
128
|
+
|
|
129
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
130
|
+
const arg = argv[index];
|
|
131
|
+
if (!arg) continue;
|
|
132
|
+
|
|
133
|
+
if (arg === "--help" || arg === "-h") {
|
|
134
|
+
console.log(HELP_TEXT);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (arg === "--json") {
|
|
139
|
+
args.isJson = true;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (arg === "--markdown") {
|
|
144
|
+
args.markdownPath = requireValue(argv, index, arg);
|
|
145
|
+
index += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (arg.startsWith("--markdown=")) {
|
|
150
|
+
args.markdownPath = arg.slice("--markdown=".length);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (arg === "--smoke-note") {
|
|
155
|
+
args.smokeNote = requireValue(argv, index, arg);
|
|
156
|
+
index += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (arg.startsWith("--smoke-note=")) {
|
|
161
|
+
args.smokeNote = arg.slice("--smoke-note=".length);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (arg === "--tier") {
|
|
166
|
+
args.tier = parseTier(requireValue(argv, index, arg));
|
|
167
|
+
index += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (arg.startsWith("--tier=")) {
|
|
172
|
+
args.tier = parseTier(arg.slice("--tier=".length));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (arg.startsWith("-")) {
|
|
177
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!args.providerPath) {
|
|
181
|
+
args.providerPath = arg;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return args;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function requireValue(argv: string[], index: number, label: string): string {
|
|
192
|
+
const value = argv[index + 1];
|
|
193
|
+
if (!value) {
|
|
194
|
+
throw new Error(`Missing value for ${label}.`);
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseTier(value: string): BountyTier {
|
|
200
|
+
if (isBountyTier(value)) {
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isBountyTier(value: string): value is BountyTier {
|
|
209
|
+
return TIER_VALUES.has(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function buildSubmitCheckReport(
|
|
213
|
+
providerRoot: string,
|
|
214
|
+
args: { smokeNote?: string; tier?: BountyTier } = {},
|
|
215
|
+
): Promise<SubmitCheckReport> {
|
|
216
|
+
const checks: SubmitCheck[] = [];
|
|
217
|
+
const baseChecks = await safeRunChecks(providerRoot);
|
|
218
|
+
const provider = await safeLoadProvider(providerRoot);
|
|
219
|
+
|
|
220
|
+
checks.push(...scoreBaseChecks(baseChecks));
|
|
221
|
+
|
|
222
|
+
if (provider) {
|
|
223
|
+
checks.push(scoreOperationMetadata(provider));
|
|
224
|
+
checks.push(scoreFixtureCoverage(provider));
|
|
225
|
+
checks.push(scoreHealthCoverage(provider));
|
|
226
|
+
checks.push(scoreAuthSafety(provider));
|
|
227
|
+
checks.push(scoreSmokeEvidence(args.smokeNote));
|
|
228
|
+
checks.push(...scoreProviderDocs(providerRoot));
|
|
229
|
+
checks.push(scoreSecrets(providerRoot));
|
|
230
|
+
} else {
|
|
231
|
+
checks.push(
|
|
232
|
+
blocker(
|
|
233
|
+
"provider-load",
|
|
234
|
+
"definition",
|
|
235
|
+
"Provider could not be loaded.",
|
|
236
|
+
"Fix index.ts so it default-exports defineProvider(...).",
|
|
237
|
+
CATEGORY_MAX_POINTS.definition,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const total = clamp(
|
|
243
|
+
Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
|
|
244
|
+
0,
|
|
245
|
+
100,
|
|
246
|
+
);
|
|
247
|
+
const blockers = checks.filter(
|
|
248
|
+
(check) => check.level === "blocker" && check.status === "fail",
|
|
249
|
+
).length;
|
|
250
|
+
const warnings = checks.filter((check) => check.status === "warn").length;
|
|
251
|
+
const passed = checks.filter((check) => check.status === "pass").length;
|
|
252
|
+
const verdict: Verdict =
|
|
253
|
+
blockers > 0
|
|
254
|
+
? "blocked"
|
|
255
|
+
: total >= 90 && warnings === 0
|
|
256
|
+
? "ready"
|
|
257
|
+
: "reviewable_with_warnings";
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
schemaVersion: 1,
|
|
261
|
+
generatedAt: new Date().toISOString(),
|
|
262
|
+
provider: {
|
|
263
|
+
id: provider?.id ?? basename(providerRoot),
|
|
264
|
+
version: provider?.version ?? "unknown",
|
|
265
|
+
runtime: provider?.runtime ?? "unknown",
|
|
266
|
+
authMode: provider?.auth?.mode ?? "none",
|
|
267
|
+
sdkVersion: packageJson.version,
|
|
268
|
+
...(args.tier ? { tier: args.tier } : {}),
|
|
269
|
+
},
|
|
270
|
+
score: { total, max: 100, verdict },
|
|
271
|
+
summary: { blockers, warnings, passed },
|
|
272
|
+
checks,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
277
|
+
try {
|
|
278
|
+
return await runChecks(providerRoot);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
message: "Base provider checks can run",
|
|
283
|
+
passed: false,
|
|
284
|
+
details: [error instanceof Error ? error.message : String(error)],
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
291
|
+
const failed = results.filter((result) => !result.passed);
|
|
292
|
+
if (failed.length > 0) {
|
|
293
|
+
return [
|
|
294
|
+
{
|
|
295
|
+
id: "base-checks",
|
|
296
|
+
category: "definition",
|
|
297
|
+
level: "blocker",
|
|
298
|
+
status: "fail",
|
|
299
|
+
points: 0,
|
|
300
|
+
maxPoints: CATEGORY_MAX_POINTS.definition,
|
|
301
|
+
message: "Base provider checks failed.",
|
|
302
|
+
remediation:
|
|
303
|
+
"Run `bun run check` and fix every failing item before bounty submission.",
|
|
304
|
+
evidence: failed.map((result) =>
|
|
305
|
+
redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
|
|
306
|
+
),
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
id: "base-checks",
|
|
314
|
+
category: "definition",
|
|
315
|
+
level: "info",
|
|
316
|
+
status: "pass",
|
|
317
|
+
points: CATEGORY_MAX_POINTS.definition,
|
|
318
|
+
maxPoints: CATEGORY_MAX_POINTS.definition,
|
|
319
|
+
message: "Base provider checks passed.",
|
|
320
|
+
evidence: results.map((result) => result.message),
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
326
|
+
const operations = Object.entries(provider.operations);
|
|
327
|
+
const weakDescriptions = operations
|
|
328
|
+
.filter(
|
|
329
|
+
([, operation]) => (operation.description ?? "").trim().length < 150,
|
|
330
|
+
)
|
|
331
|
+
.map(([operationId]) => operationId);
|
|
332
|
+
const missingAnnotations = operations
|
|
333
|
+
.filter(([, operation]) => !operation.annotations)
|
|
334
|
+
.map(([operationId]) => operationId);
|
|
335
|
+
|
|
336
|
+
if (weakDescriptions.length > 0) {
|
|
337
|
+
return {
|
|
338
|
+
id: "operation-metadata",
|
|
339
|
+
category: "operations",
|
|
340
|
+
level: "blocker",
|
|
341
|
+
status: "fail",
|
|
342
|
+
points: 0,
|
|
343
|
+
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
344
|
+
message: "One or more operations have weak descriptions.",
|
|
345
|
+
remediation:
|
|
346
|
+
"Add 150+ character English descriptions explaining when to use, when not to use, outputs, and caveats.",
|
|
347
|
+
evidence: weakDescriptions,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const points =
|
|
352
|
+
missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
353
|
+
return {
|
|
354
|
+
id: "operation-metadata",
|
|
355
|
+
category: "operations",
|
|
356
|
+
level: missingAnnotations.length > 0 ? "warn" : "info",
|
|
357
|
+
status: missingAnnotations.length > 0 ? "warn" : "pass",
|
|
358
|
+
points,
|
|
359
|
+
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
360
|
+
message:
|
|
361
|
+
missingAnnotations.length > 0
|
|
362
|
+
? "Operations are described, but some are missing safety annotations."
|
|
363
|
+
: "Operation descriptions and metadata are review-ready.",
|
|
364
|
+
remediation:
|
|
365
|
+
missingAnnotations.length > 0
|
|
366
|
+
? "Add annotations such as readOnly, destructive, idempotent, openWorld, rateLimit, or timeoutMs where applicable."
|
|
367
|
+
: undefined,
|
|
368
|
+
evidence:
|
|
369
|
+
missingAnnotations.length > 0
|
|
370
|
+
? missingAnnotations.map(
|
|
371
|
+
(operationId) => `${operationId}: missing annotations`,
|
|
372
|
+
)
|
|
373
|
+
: operations.map(([operationId]) => operationId),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
378
|
+
const missing = Object.entries(provider.operations)
|
|
379
|
+
.filter(
|
|
380
|
+
([, operation]) =>
|
|
381
|
+
!operation.fixtures?.request || !operation.fixtures?.response,
|
|
382
|
+
)
|
|
383
|
+
.map(([operationId]) => operationId);
|
|
384
|
+
if (missing.length > 0) {
|
|
385
|
+
return blocker(
|
|
386
|
+
"fixtures",
|
|
387
|
+
"fixtures",
|
|
388
|
+
"One or more operations are missing bidirectional fixtures.",
|
|
389
|
+
"Add fixtures.request and fixtures.response that parse against operation schemas.",
|
|
390
|
+
CATEGORY_MAX_POINTS.fixtures,
|
|
391
|
+
missing,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return pass(
|
|
395
|
+
"fixtures",
|
|
396
|
+
"fixtures",
|
|
397
|
+
"All operations include bidirectional fixtures.",
|
|
398
|
+
CATEGORY_MAX_POINTS.fixtures,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
403
|
+
const operations = Object.entries(provider.operations);
|
|
404
|
+
const missing: string[] = [];
|
|
405
|
+
const placeholder: string[] = [];
|
|
406
|
+
const unsupported: string[] = [];
|
|
407
|
+
const generatedStarter: string[] = [];
|
|
408
|
+
|
|
409
|
+
for (const [operationId, operation] of operations) {
|
|
410
|
+
const hasCheck = operation.healthCheck !== undefined;
|
|
411
|
+
const hasUnsupported = operation.healthCheckUnsupported !== undefined;
|
|
412
|
+
if (!hasCheck && !hasUnsupported) {
|
|
413
|
+
missing.push(operationId);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (hasUnsupported) {
|
|
417
|
+
const reason = operation.healthCheckUnsupported?.reason ?? "";
|
|
418
|
+
unsupported.push(operationId);
|
|
419
|
+
if (/generated local-only scaffold/i.test(reason)) {
|
|
420
|
+
generatedStarter.push(operationId);
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
|
|
424
|
+
reason,
|
|
425
|
+
)
|
|
426
|
+
) {
|
|
427
|
+
placeholder.push(operationId);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (missing.length > 0) {
|
|
433
|
+
return blocker(
|
|
434
|
+
"health-coverage",
|
|
435
|
+
"health",
|
|
436
|
+
"One or more operations lack healthCheck or healthCheckUnsupported.",
|
|
437
|
+
"Declare a safe healthCheck for read-only upstream probes or a specific healthCheckUnsupported.reason.",
|
|
438
|
+
CATEGORY_MAX_POINTS.health,
|
|
439
|
+
missing,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (placeholder.length > 0) {
|
|
444
|
+
return {
|
|
445
|
+
id: "health-coverage",
|
|
446
|
+
category: "health",
|
|
447
|
+
level: "warn",
|
|
448
|
+
status: "warn",
|
|
449
|
+
points: 8,
|
|
450
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
451
|
+
message: "Some healthCheckUnsupported reasons look placeholder-like.",
|
|
452
|
+
remediation:
|
|
453
|
+
"Replace placeholder rationale with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.",
|
|
454
|
+
evidence: placeholder,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (generatedStarter.length > 0) {
|
|
459
|
+
return {
|
|
460
|
+
id: "health-coverage",
|
|
461
|
+
category: "health",
|
|
462
|
+
level: "warn",
|
|
463
|
+
status: "warn",
|
|
464
|
+
points: 10,
|
|
465
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
466
|
+
message:
|
|
467
|
+
"Generated starter operation health rationale is present; replace starter logic before bounty submission.",
|
|
468
|
+
remediation:
|
|
469
|
+
"Replace `ping` with real upstream-backed operations and prefer real healthCheck for safe read-only probes.",
|
|
470
|
+
evidence: generatedStarter,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (unsupported.length > 0) {
|
|
475
|
+
return {
|
|
476
|
+
id: "health-coverage",
|
|
477
|
+
category: "health",
|
|
478
|
+
level: "warn",
|
|
479
|
+
status: "warn",
|
|
480
|
+
points: 12,
|
|
481
|
+
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
482
|
+
message:
|
|
483
|
+
"Health coverage is declared, with one or more unsupported probes.",
|
|
484
|
+
remediation:
|
|
485
|
+
"Reviewers prefer real healthCheck for safe read-only upstream operations.",
|
|
486
|
+
evidence: unsupported.map(
|
|
487
|
+
(operationId) => `${operationId}: healthCheckUnsupported`,
|
|
488
|
+
),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return pass(
|
|
493
|
+
"health-coverage",
|
|
494
|
+
"health",
|
|
495
|
+
"All operations declare real health checks.",
|
|
496
|
+
CATEGORY_MAX_POINTS.health,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
501
|
+
if (smokeNote?.trim()) {
|
|
502
|
+
return {
|
|
503
|
+
id: "local-smoke",
|
|
504
|
+
category: "smoke",
|
|
505
|
+
level: "info",
|
|
506
|
+
status: "pass",
|
|
507
|
+
points: CATEGORY_MAX_POINTS.smoke,
|
|
508
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
509
|
+
message: "Local smoke evidence was provided.",
|
|
510
|
+
evidence: [redact(smokeNote.trim())],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
id: "local-smoke",
|
|
516
|
+
category: "smoke",
|
|
517
|
+
level: "warn",
|
|
518
|
+
status: "warn",
|
|
519
|
+
points: 5,
|
|
520
|
+
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
521
|
+
message: "No local smoke evidence was provided.",
|
|
522
|
+
remediation:
|
|
523
|
+
"Start `bun run dev`, call `/health` and at least one `POST /v1/{operation}`, then rerun with `--smoke-note` or paste notes in the bounty issue.",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
528
|
+
const authMode = provider.auth?.mode ?? "none";
|
|
529
|
+
const credentialKeys = provider.credential?.keys ?? [];
|
|
530
|
+
if (authMode === "credentials" && credentialKeys.length === 0) {
|
|
531
|
+
return blocker(
|
|
532
|
+
"auth-safety",
|
|
533
|
+
"auth",
|
|
534
|
+
"Credential-backed auth mode is missing credential.keys.",
|
|
535
|
+
"Declare credential.keys and document local-only connection.secrets debugging.",
|
|
536
|
+
CATEGORY_MAX_POINTS.auth,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (authMode === "oauth2" && credentialKeys.length === 0) {
|
|
541
|
+
return {
|
|
542
|
+
id: "auth-safety",
|
|
543
|
+
category: "auth",
|
|
544
|
+
level: "warn",
|
|
545
|
+
status: "warn",
|
|
546
|
+
points: 7,
|
|
547
|
+
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
548
|
+
message: "OAuth auth mode does not declare persisted credential.keys.",
|
|
549
|
+
remediation:
|
|
550
|
+
"Generated OAuth starters may begin without keys, but bounty-ready OAuth providers should declare persisted token keys once the real token exchange is implemented.",
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (authMode === "none") {
|
|
555
|
+
const securedOperations = Object.entries(provider.operations).filter(
|
|
556
|
+
([, operation]) => operation.annotations?.openWorld === false,
|
|
557
|
+
);
|
|
558
|
+
if (securedOperations.length > 0) {
|
|
559
|
+
return {
|
|
560
|
+
id: "auth-safety",
|
|
561
|
+
category: "auth",
|
|
562
|
+
level: "warn",
|
|
563
|
+
status: "warn",
|
|
564
|
+
points: 7,
|
|
565
|
+
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
566
|
+
message:
|
|
567
|
+
"Provider is no-auth but at least one operation is not marked openWorld.",
|
|
568
|
+
remediation:
|
|
569
|
+
"Confirm auth.mode and operation annotations match the actual upstream auth model.",
|
|
570
|
+
evidence: securedOperations.map(([operationId]) => operationId),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return pass(
|
|
576
|
+
"auth-safety",
|
|
577
|
+
"auth",
|
|
578
|
+
"Auth and credential declarations are internally consistent.",
|
|
579
|
+
CATEGORY_MAX_POINTS.auth,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
584
|
+
const readmePath = resolve(providerRoot, "README.md");
|
|
585
|
+
if (!existsSync(readmePath)) {
|
|
586
|
+
return [
|
|
587
|
+
{
|
|
588
|
+
id: "submission-docs",
|
|
589
|
+
category: "docs",
|
|
590
|
+
level: "warn",
|
|
591
|
+
status: "warn",
|
|
592
|
+
points: 4,
|
|
593
|
+
maxPoints: CATEGORY_MAX_POINTS.docs,
|
|
594
|
+
message: "Provider README.md is missing.",
|
|
595
|
+
remediation:
|
|
596
|
+
"Add README sections for parameters, response shape, examples, auth/env setup, health coverage, and known upstream constraints.",
|
|
597
|
+
},
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const readme = readFileSync(readmePath, "utf8").toLowerCase();
|
|
602
|
+
const missing = [
|
|
603
|
+
["parameters", "Parameters"],
|
|
604
|
+
["response", "Response"],
|
|
605
|
+
["example", "Example"],
|
|
606
|
+
].filter(([needle]) => !readme.includes(needle));
|
|
607
|
+
const mentionsSubmitCheck = readme.includes("submit-check");
|
|
608
|
+
|
|
609
|
+
const points = Math.max(
|
|
610
|
+
0,
|
|
611
|
+
CATEGORY_MAX_POINTS.docs -
|
|
612
|
+
missing.length * 2 -
|
|
613
|
+
(mentionsSubmitCheck ? 0 : 1),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
return [
|
|
617
|
+
{
|
|
618
|
+
id: "submission-docs",
|
|
619
|
+
category: "docs",
|
|
620
|
+
level: missing.length > 0 || !mentionsSubmitCheck ? "warn" : "info",
|
|
621
|
+
status: missing.length > 0 || !mentionsSubmitCheck ? "warn" : "pass",
|
|
622
|
+
points,
|
|
623
|
+
maxPoints: CATEGORY_MAX_POINTS.docs,
|
|
624
|
+
message:
|
|
625
|
+
missing.length > 0 || !mentionsSubmitCheck
|
|
626
|
+
? "Provider README is present but missing some submission evidence guidance."
|
|
627
|
+
: "Provider README includes expected submission guidance.",
|
|
628
|
+
remediation:
|
|
629
|
+
missing.length > 0 || !mentionsSubmitCheck
|
|
630
|
+
? "Include Parameters, Response, Example, and submit-check evidence guidance."
|
|
631
|
+
: undefined,
|
|
632
|
+
evidence: [
|
|
633
|
+
...missing.map(([, label]) => `missing ${label}`),
|
|
634
|
+
...(mentionsSubmitCheck ? [] : ["missing submit-check mention"]),
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
641
|
+
const findings = findSecretFindings(providerRoot);
|
|
642
|
+
if (findings.length > 0) {
|
|
643
|
+
return {
|
|
644
|
+
id: "secret-scan",
|
|
645
|
+
category: "security",
|
|
646
|
+
level: "blocker",
|
|
647
|
+
status: "fail",
|
|
648
|
+
points: 0,
|
|
649
|
+
maxPoints: CATEGORY_MAX_POINTS.security,
|
|
650
|
+
message:
|
|
651
|
+
"Potential real credential material was found in shareable files.",
|
|
652
|
+
remediation:
|
|
653
|
+
"Remove real secrets from source, README, and fixtures. Use environment variables and local-only connection.secrets instead.",
|
|
654
|
+
evidence: findings.map((finding) => `${finding.file}: ${finding.label}`),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return pass(
|
|
659
|
+
"secret-scan",
|
|
660
|
+
"security",
|
|
661
|
+
"No high-confidence secrets were found in README, source, package, or fixtures.",
|
|
662
|
+
CATEGORY_MAX_POINTS.security,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
667
|
+
const candidateFiles = [
|
|
668
|
+
"README.md",
|
|
669
|
+
"index.ts",
|
|
670
|
+
"package.json",
|
|
671
|
+
"__fixtures__/raw.json",
|
|
672
|
+
"__fixtures__/transform.snap.json",
|
|
673
|
+
];
|
|
674
|
+
const findings: SecretFinding[] = [];
|
|
675
|
+
|
|
676
|
+
for (const relativePath of candidateFiles) {
|
|
677
|
+
const filePath = resolve(providerRoot, relativePath);
|
|
678
|
+
if (!existsSync(filePath)) continue;
|
|
679
|
+
const content = readFileSync(filePath, "utf8");
|
|
680
|
+
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
681
|
+
if (pattern.test(content)) {
|
|
682
|
+
findings.push({ label, file: relativePath });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return findings;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
691
|
+
[
|
|
692
|
+
"JWT-like token",
|
|
693
|
+
/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
|
|
694
|
+
],
|
|
695
|
+
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
696
|
+
["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
|
|
697
|
+
["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
|
|
698
|
+
[
|
|
699
|
+
"credential field",
|
|
700
|
+
/"(?:apiKey|api_key|accessToken|access_token|refreshToken|refresh_token|password|secret|sessionCookie|cookie)"\s*:\s*"(?!dev-only|local|example|sample|your-|replace|<)[^"]{16,}"/i,
|
|
701
|
+
],
|
|
702
|
+
];
|
|
703
|
+
|
|
704
|
+
async function safeLoadProvider(
|
|
705
|
+
providerRoot: string,
|
|
706
|
+
): Promise<ProviderDefinition | undefined> {
|
|
707
|
+
try {
|
|
708
|
+
return await loadProvider(providerRoot);
|
|
709
|
+
} catch {
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function loadProvider(
|
|
715
|
+
providerRoot: string,
|
|
716
|
+
): Promise<ProviderDefinition | undefined> {
|
|
717
|
+
const entryPath = resolve(providerRoot, "index.ts");
|
|
718
|
+
if (!existsSync(entryPath)) {
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
const module = (await import(pathToFileURL(entryPath).href)) as {
|
|
722
|
+
default?: ProviderDefinition;
|
|
723
|
+
};
|
|
724
|
+
return module.default;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function resolveProviderRoot(inputPath: string): string {
|
|
728
|
+
let current = resolve(process.cwd(), inputPath);
|
|
729
|
+
if (!existsSync(current)) {
|
|
730
|
+
throw new Error(`Provider path not found: ${inputPath}`);
|
|
731
|
+
}
|
|
732
|
+
if (!existsSync(resolve(current, "index.ts"))) {
|
|
733
|
+
current = dirname(current);
|
|
734
|
+
}
|
|
735
|
+
while (!existsSync(resolve(current, "index.ts"))) {
|
|
736
|
+
const parent = dirname(current);
|
|
737
|
+
if (parent === current) {
|
|
738
|
+
throw new Error(`Could not find provider root for: ${inputPath}`);
|
|
739
|
+
}
|
|
740
|
+
current = parent;
|
|
741
|
+
}
|
|
742
|
+
return current;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function pass(
|
|
746
|
+
id: string,
|
|
747
|
+
category: string,
|
|
748
|
+
message: string,
|
|
749
|
+
points: number,
|
|
750
|
+
evidence?: string[],
|
|
751
|
+
): SubmitCheck {
|
|
752
|
+
return {
|
|
753
|
+
id,
|
|
754
|
+
category,
|
|
755
|
+
level: "info",
|
|
756
|
+
status: "pass",
|
|
757
|
+
points,
|
|
758
|
+
maxPoints: points,
|
|
759
|
+
message,
|
|
760
|
+
...(evidence ? { evidence } : {}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function blocker(
|
|
765
|
+
id: string,
|
|
766
|
+
category: string,
|
|
767
|
+
message: string,
|
|
768
|
+
remediation: string,
|
|
769
|
+
maxPoints: number,
|
|
770
|
+
evidence?: string[],
|
|
771
|
+
): SubmitCheck {
|
|
772
|
+
return {
|
|
773
|
+
id,
|
|
774
|
+
category,
|
|
775
|
+
level: "blocker",
|
|
776
|
+
status: "fail",
|
|
777
|
+
points: 0,
|
|
778
|
+
maxPoints,
|
|
779
|
+
message,
|
|
780
|
+
remediation,
|
|
781
|
+
...(evidence ? { evidence: evidence.map(redact) } : {}),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function renderText(report: SubmitCheckReport): string {
|
|
786
|
+
const lines = [
|
|
787
|
+
`ApiFuse Provider Submission Score: ${report.score.total} / ${report.score.max}`,
|
|
788
|
+
`Verdict: ${report.score.verdict.toUpperCase()}`,
|
|
789
|
+
`Provider: ${report.provider.id}@${report.provider.version} (${report.provider.runtime}, auth: ${report.provider.authMode})`,
|
|
790
|
+
`Blockers: ${report.summary.blockers} Warnings: ${report.summary.warnings} Passed: ${report.summary.passed}`,
|
|
791
|
+
"",
|
|
792
|
+
"Checklist:",
|
|
793
|
+
];
|
|
794
|
+
|
|
795
|
+
for (const check of report.checks) {
|
|
796
|
+
const marker =
|
|
797
|
+
check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
798
|
+
lines.push(
|
|
799
|
+
`${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
|
|
800
|
+
);
|
|
801
|
+
if (check.remediation) {
|
|
802
|
+
lines.push(` Fix: ${check.remediation}`);
|
|
803
|
+
}
|
|
804
|
+
for (const evidence of check.evidence ?? []) {
|
|
805
|
+
lines.push(` - ${redact(evidence)}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return lines.join("\n");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function renderMarkdown(report: SubmitCheckReport): string {
|
|
813
|
+
const lines = [
|
|
814
|
+
"# ApiFuse Provider Submission Report",
|
|
815
|
+
"",
|
|
816
|
+
`- **Provider**: ${report.provider.id}@${report.provider.version}`,
|
|
817
|
+
`- **SDK**: ${report.provider.sdkVersion}`,
|
|
818
|
+
`- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
|
|
819
|
+
...(report.provider.tier
|
|
820
|
+
? [`- **Bounty tier**: ${report.provider.tier}`]
|
|
821
|
+
: []),
|
|
822
|
+
`- **Score**: ${report.score.total}/${report.score.max}`,
|
|
823
|
+
`- **Verdict**: ${report.score.verdict}`,
|
|
824
|
+
`- **Blockers**: ${report.summary.blockers}`,
|
|
825
|
+
`- **Warnings**: ${report.summary.warnings}`,
|
|
826
|
+
"",
|
|
827
|
+
"## Checklist",
|
|
828
|
+
"",
|
|
829
|
+
"| Status | Category | Check | Points | Remediation |",
|
|
830
|
+
"|---|---|---|---:|---|",
|
|
831
|
+
];
|
|
832
|
+
|
|
833
|
+
for (const check of report.checks) {
|
|
834
|
+
const status =
|
|
835
|
+
check.status === "pass"
|
|
836
|
+
? "PASS"
|
|
837
|
+
: check.status === "warn"
|
|
838
|
+
? "WARN"
|
|
839
|
+
: "FAIL";
|
|
840
|
+
lines.push(
|
|
841
|
+
`| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const evidence = report.checks.flatMap((check) =>
|
|
846
|
+
(check.evidence ?? []).map((item) => `- **${check.id}**: ${redact(item)}`),
|
|
847
|
+
);
|
|
848
|
+
if (evidence.length > 0) {
|
|
849
|
+
lines.push("", "## Evidence", "", ...evidence);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
lines.push("");
|
|
853
|
+
return `${lines.join("\n")}\n`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function escapeMarkdown(value: string): string {
|
|
857
|
+
return redact(value).replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function redact(value: string): string {
|
|
861
|
+
let output = value;
|
|
862
|
+
for (const [, pattern] of SECRET_PATTERNS) {
|
|
863
|
+
output = output.replace(toGlobalRegex(pattern), "[REDACTED]");
|
|
864
|
+
}
|
|
865
|
+
return output;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function toGlobalRegex(pattern: RegExp): RegExp {
|
|
869
|
+
return pattern.global
|
|
870
|
+
? pattern
|
|
871
|
+
: new RegExp(pattern.source, `${pattern.flags}g`);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function clamp(value: number, min: number, max: number): number {
|
|
875
|
+
return Math.min(max, Math.max(min, value));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (import.meta.main) {
|
|
879
|
+
await main();
|
|
880
|
+
}
|