@apifuse/provider-sdk 2.1.0-beta.2 → 2.1.0-beta.4

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