@apifuse/provider-sdk 2.2.0-beta.44 → 2.2.0-beta.46

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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.44",
2
+ "version": "2.2.0-beta.46",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -21,6 +21,7 @@ export const DECLARATION_RULE_IDS = {
21
21
  proxyNoMixedVendors: "proxy-no-mixed-vendors",
22
22
  proxySmartproxyGeo: "proxy-smartproxy-country-only",
23
23
  operationUpstreamProxy: "operation-upstream-proxy-unsupported",
24
+ pinnedWireFieldPathValid: "public-schema-pinned-wire-field-valid",
24
25
  } as const;
25
26
 
26
27
  export type DeclarationRuleId =
@@ -53,13 +54,14 @@ export function declarationInvalidError(
53
54
  export function validateFailClosedDeclaration(provider: ProviderDefinition): void {
54
55
  const violations: DeclarationViolation[] = [];
55
56
  validateHealthDeclaration(provider, violations);
57
+ validatePinnedWireFieldPaths(provider, violations);
56
58
  validateSchemaDeclaration(provider, violations);
57
59
  validateProxyDeclaration(provider, violations);
58
60
  validateOperationDeclaration(provider, violations);
59
61
  if (violations.length > 0) throw declarationInvalidError(violations);
60
62
  }
61
63
 
62
- type ProviderDeclarationRulesInput = Pick<ProviderDefinition, "healthJourneys" | "proxy">;
64
+ type ProviderDeclarationRulesInput = Pick<ProviderDefinition, "healthJourneys" | "meta" | "proxy">;
63
65
  type OperationDeclarationRulesInput = Pick<ProviderDefinition, "operations">;
64
66
 
65
67
  /** Enforces fail-closed rules that only depend on the provider declaration. */
@@ -85,9 +87,71 @@ function collectProviderDeclarationViolations(
85
87
  violations: DeclarationViolation[],
86
88
  ): void {
87
89
  validateHealthDeclaration(provider, violations);
90
+ validatePinnedWireFieldPaths(provider, violations);
88
91
  validateProxyDeclaration(provider, violations);
89
92
  }
90
93
 
94
+ function validatePinnedWireFieldPaths(
95
+ provider: ProviderDeclarationRulesInput,
96
+ violations: DeclarationViolation[],
97
+ ): void {
98
+ const pins = provider.meta?.contract?.pinnedWireFieldPaths;
99
+ if (pins === undefined) return;
100
+
101
+ if (!Array.isArray(pins)) {
102
+ violations.push({
103
+ ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
104
+ path: "meta.contract.pinnedWireFieldPaths",
105
+ message: "pinned wire field paths must be an array.",
106
+ fix: "Declare a readonly array of { path, reason } entries.",
107
+ });
108
+ return;
109
+ }
110
+
111
+ const seenPaths = new Set<string>();
112
+ for (const [index, pin] of pins.entries()) {
113
+ const entryPath = `meta.contract.pinnedWireFieldPaths[${index}]`;
114
+ if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
115
+ violations.push({
116
+ ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
117
+ path: entryPath,
118
+ message: "each pinned wire field path must be an object.",
119
+ fix: `Replace ${entryPath} with { path: "operations.<operation-id>.<field-path>", reason: "<non-empty reason>" }.`,
120
+ });
121
+ continue;
122
+ }
123
+
124
+ const path = "path" in pin ? pin.path : undefined;
125
+ const reason = "reason" in pin ? pin.reason : undefined;
126
+ if (typeof path !== "string" || path.trim().length === 0) {
127
+ violations.push({
128
+ ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
129
+ path: `${entryPath}.path`,
130
+ message: "path must be a non-empty string.",
131
+ fix: `Set ${entryPath}.path to the exact public-schema-upstream-field diagnostic path.`,
132
+ });
133
+ } else if (seenPaths.has(path)) {
134
+ violations.push({
135
+ ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
136
+ path: `${entryPath}.path`,
137
+ message: `duplicate pinned wire field path ${JSON.stringify(path)}.`,
138
+ fix: `Keep exactly one declaration for ${JSON.stringify(path)}.`,
139
+ });
140
+ } else {
141
+ seenPaths.add(path);
142
+ }
143
+
144
+ if (typeof reason !== "string" || reason.trim().length === 0) {
145
+ violations.push({
146
+ ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
147
+ path: `${entryPath}.reason`,
148
+ message: "reason must be a non-empty string.",
149
+ fix: `Document why renaming the exact wire field would break the upstream contract.`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
91
155
  function collectOperationDeclarationViolations(
92
156
  provider: OperationDeclarationRulesInput,
93
157
  violations: DeclarationViolation[],
package/src/define.ts CHANGED
@@ -638,6 +638,10 @@ export interface ProviderDeclaration {
638
638
  implementationProfile?: ProviderImplementationProfile;
639
639
  contract?: {
640
640
  publicSchemaFieldNames?: "normalized";
641
+ readonly pinnedWireFieldPaths?: readonly {
642
+ readonly path: string;
643
+ readonly reason: string;
644
+ }[];
641
645
  };
642
646
  };
643
647
  healthMonitor?: ProviderHealthMonitorConfig;
package/src/lint.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createRequire } from "node:module";
2
+
1
3
  import type { ZodType } from "zod";
2
4
 
3
5
  import {
@@ -7,6 +9,17 @@ import {
7
9
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
8
10
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
9
11
 
12
+ const requireModule = createRequire(import.meta.url);
13
+ // `typeof import(...)` keeps the type without emitting a static import: the
14
+ // typescript package is a CLI-only dependency and src/lint.ts is production
15
+ // runtime, which the typescript-import-boundary test enforces.
16
+ let typeScriptModule: typeof import("typescript") | undefined;
17
+
18
+ function getTypeScript(): typeof import("typescript") {
19
+ typeScriptModule ??= requireModule("typescript") as typeof import("typescript");
20
+ return typeScriptModule;
21
+ }
22
+
10
23
  type AuthModeLike =
11
24
  | "none"
12
25
  | "platform-managed"
@@ -120,6 +133,10 @@ function isAuthLifecycleOperationId(operationId: string, authMode: string): bool
120
133
 
121
134
  type ProviderContractMetaLike = {
122
135
  publicSchemaFieldNames?: "normalized";
136
+ pinnedWireFieldPaths?: readonly {
137
+ readonly path: string;
138
+ readonly reason: string;
139
+ }[];
123
140
  };
124
141
 
125
142
  type SchemaLike = ZodType & {
@@ -153,6 +170,17 @@ type ProviderLintOptions = {
153
170
  mode?: ProviderLintMode;
154
171
  };
155
172
 
173
+ export interface ProviderLintInformation {
174
+ rule: string;
175
+ message: string;
176
+ field?: string;
177
+ }
178
+
179
+ export interface ProviderLintResult {
180
+ diagnostics: LintDiagnostic[];
181
+ information: ProviderLintInformation[];
182
+ }
183
+
156
184
  type ProviderSourceLike = {
157
185
  authFlowSource?: string;
158
186
  providerSourceFiles?: Record<string, string>;
@@ -894,6 +922,146 @@ function lintSelfHostedBrowserPatterns(
894
922
  const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
895
923
 
896
924
  const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
925
+ const RECORDED_FIXTURE_SOURCE_FILE_PATTERN =
926
+ /(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
927
+ const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
928
+ const VERSIONED_PROFILE_LITERAL_PATTERN =
929
+ /\b(?:chrome|chromium|firefox|safari|edge|opera|ios[-_]safari)[-_]\d+(?:[._-]\d+)*(?=$|[^A-Za-z0-9])/i;
930
+ const VERSIONED_USER_AGENT_PATTERN = /\b(?:Chrome|CriOS|Firefox|FxiOS|EdgA?|OPR)\/\d+(?:\.\d+)*/i;
931
+ const VERSIONED_SAFARI_USER_AGENT_PATTERN = /\bVersion\/(\d+(?:\.\d+)*)(?=[\s\S]*\bSafari\/\d)/i;
932
+ const VERSIONED_CLIENT_HINT_PATTERN = /(?:^|[;,\s])v\s*=\s*["']?\d+/i;
933
+
934
+ type BrowserVersionLiteralKind = "profile" | "user-agent" | "sec-ch-ua";
935
+
936
+ type BrowserVersionLiteralFinding = {
937
+ kind: BrowserVersionLiteralKind;
938
+ literal: string;
939
+ position: number;
940
+ };
941
+
942
+ function staticStringText(node: import("typescript").Node | undefined): string | undefined {
943
+ if (!node) return undefined;
944
+ const ts = getTypeScript();
945
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
946
+ return undefined;
947
+ }
948
+
949
+ function staticPropertyName(node: import("typescript").PropertyName): string | undefined {
950
+ const ts = getTypeScript();
951
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
952
+ return node.text;
953
+ }
954
+ if (ts.isComputedPropertyName(node)) return staticStringText(node.expression);
955
+ return undefined;
956
+ }
957
+
958
+ function isSecChUaHeaderName(value: string | undefined): boolean {
959
+ return value?.toLowerCase() === "sec-ch-ua";
960
+ }
961
+
962
+ function collectBrowserVersionLiteralFindings(source: string): BrowserVersionLiteralFinding[] {
963
+ const ts = getTypeScript();
964
+ const sourceFile = ts.createSourceFile(
965
+ "provider-source.ts",
966
+ source,
967
+ ts.ScriptTarget.Latest,
968
+ true,
969
+ ts.ScriptKind.TSX,
970
+ );
971
+ const findings: BrowserVersionLiteralFinding[] = [];
972
+ const seen = new Set<string>();
973
+
974
+ const addFinding = (kind: BrowserVersionLiteralKind, literal: string, position: number) => {
975
+ const key = `${kind}:${position}:${literal}`;
976
+ if (seen.has(key)) return;
977
+ seen.add(key);
978
+ findings.push({ kind, literal, position });
979
+ };
980
+
981
+ const inspectLiteral = (text: string, position: number) => {
982
+ const profile = text.match(VERSIONED_PROFILE_LITERAL_PATTERN)?.[0];
983
+ if (profile) addFinding("profile", profile, position);
984
+
985
+ const userAgent =
986
+ text.match(VERSIONED_USER_AGENT_PATTERN)?.[0] ??
987
+ text.match(VERSIONED_SAFARI_USER_AGENT_PATTERN)?.[0];
988
+ if (userAgent) addFinding("user-agent", userAgent, position);
989
+ };
990
+
991
+ const inspectSecChUaValue = (node: import("typescript").Node | undefined) => {
992
+ const text = staticStringText(node);
993
+ if (text && VERSIONED_CLIENT_HINT_PATTERN.test(text)) {
994
+ addFinding("sec-ch-ua", "sec-ch-ua", node!.getStart(sourceFile));
995
+ }
996
+ };
997
+
998
+ const visit = (node: import("typescript").Node) => {
999
+ const text = staticStringText(node);
1000
+ if (text !== undefined) inspectLiteral(text, node.getStart(sourceFile));
1001
+
1002
+ if (ts.isPropertyAssignment(node) && isSecChUaHeaderName(staticPropertyName(node.name))) {
1003
+ inspectSecChUaValue(node.initializer);
1004
+ }
1005
+
1006
+ if (ts.isCallExpression(node) && isSecChUaHeaderName(staticStringText(node.arguments[0]))) {
1007
+ inspectSecChUaValue(node.arguments[1]);
1008
+ }
1009
+
1010
+ if (
1011
+ ts.isArrayLiteralExpression(node) &&
1012
+ isSecChUaHeaderName(staticStringText(node.elements[0]))
1013
+ ) {
1014
+ inspectSecChUaValue(node.elements[1]);
1015
+ }
1016
+
1017
+ ts.forEachChild(node, visit);
1018
+ };
1019
+
1020
+ visit(sourceFile);
1021
+ return findings.sort((left, right) => left.position - right.position);
1022
+ }
1023
+
1024
+ function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): string {
1025
+ switch (finding.kind) {
1026
+ case "profile":
1027
+ return `Hardcoded stealth profile "${finding.literal}" pins a browser version and will rot. Use the matching intent alias instead: "chrome-desktop", "firefox-desktop", "safari-desktop", or "safari-mobile".`;
1028
+ case "user-agent":
1029
+ return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal and derive it from the matching intent profile, for example getStealthProfile("chrome-desktop").userAgent.`;
1030
+ case "sec-ch-ua":
1031
+ return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from an intent profile such as "chrome-desktop"; derive any explicit User-Agent with getStealthProfile("chrome-desktop").userAgent.';
1032
+ }
1033
+ }
1034
+
1035
+ function lintBrowserVersionLiterals(provider: ProviderSourceLike): LintDiagnostic[] {
1036
+ const sources: Array<{ field: string; source: string }> = [];
1037
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1038
+ ([filePath]) =>
1039
+ JAVASCRIPT_SOURCE_FILE_PATTERN.test(filePath) &&
1040
+ !TEST_SOURCE_FILE_PATTERN.test(filePath) &&
1041
+ !RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(filePath),
1042
+ );
1043
+ if (sourceFiles.length > 0) {
1044
+ for (const [filePath, source] of sourceFiles) {
1045
+ sources.push({ field: `sourceFiles.${filePath}`, source });
1046
+ }
1047
+ } else {
1048
+ if (provider.authFlowSource)
1049
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1050
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1051
+ const source = getOperationSource(operation);
1052
+ if (source) sources.push({ field: `operations.${operationKey}.handler`, source });
1053
+ }
1054
+ }
1055
+
1056
+ return sources.flatMap(({ field, source }) =>
1057
+ collectBrowserVersionLiteralFindings(source).map((finding) => ({
1058
+ rule: "browser-version-literal",
1059
+ level: "error" as const,
1060
+ field,
1061
+ message: browserVersionLiteralMessage(finding),
1062
+ })),
1063
+ );
1064
+ }
897
1065
 
898
1066
  /**
899
1067
  * Skips a string literal starting at `startIndex` (which must point at the
@@ -1298,6 +1466,67 @@ export function lintOperation(op: {
1298
1466
  return diagnostics;
1299
1467
  }
1300
1468
 
1469
+ function declaredPinnedWireFieldPaths(provider: {
1470
+ meta?: { contract?: ProviderContractMetaLike };
1471
+ }): readonly { readonly path: string; readonly reason: string }[] {
1472
+ const pins = provider.meta?.contract?.pinnedWireFieldPaths;
1473
+ if (!Array.isArray(pins)) return [];
1474
+ return pins.filter(
1475
+ (pin) =>
1476
+ pin !== null &&
1477
+ typeof pin === "object" &&
1478
+ typeof pin.path === "string" &&
1479
+ pin.path.trim().length > 0 &&
1480
+ typeof pin.reason === "string" &&
1481
+ pin.reason.trim().length > 0,
1482
+ );
1483
+ }
1484
+
1485
+ function applyPinnedWireFieldPaths(
1486
+ provider: { meta?: { contract?: ProviderContractMetaLike } },
1487
+ diagnostics: readonly LintDiagnostic[],
1488
+ ): ProviderLintResult {
1489
+ const pins = declaredPinnedWireFieldPaths(provider);
1490
+ if (pins.length === 0) {
1491
+ return { diagnostics: [...diagnostics], information: [] };
1492
+ }
1493
+
1494
+ const pinsByPath = new Map(pins.map((pin) => [pin.path, pin]));
1495
+ const matchedPaths = new Set<string>();
1496
+ const remainingDiagnostics = diagnostics.filter((diagnostic) => {
1497
+ if (
1498
+ diagnostic.rule !== "public-schema-upstream-field" ||
1499
+ diagnostic.field === undefined ||
1500
+ !pinsByPath.has(diagnostic.field)
1501
+ ) {
1502
+ return true;
1503
+ }
1504
+ matchedPaths.add(diagnostic.field);
1505
+ return false;
1506
+ });
1507
+
1508
+ const information: ProviderLintInformation[] = [];
1509
+ for (const pin of pins) {
1510
+ if (matchedPaths.has(pin.path)) {
1511
+ information.push({
1512
+ rule: "public-schema-pinned-wire-field",
1513
+ field: pin.path,
1514
+ message: `Suppressed exact public-schema-upstream-field diagnostic. Reason: ${pin.reason}`,
1515
+ });
1516
+ continue;
1517
+ }
1518
+
1519
+ remainingDiagnostics.push({
1520
+ rule: "public-schema-pinned-wire-field-stale",
1521
+ level: "error",
1522
+ field: pin.path,
1523
+ message: `Pinned wire field path ${JSON.stringify(pin.path)} matches no current public-schema-upstream-field diagnostic; remove the stale declaration.`,
1524
+ });
1525
+ }
1526
+
1527
+ return { diagnostics: remainingDiagnostics, information };
1528
+ }
1529
+
1301
1530
  export function lintProvider(
1302
1531
  provider: {
1303
1532
  id?: string;
@@ -1340,6 +1569,14 @@ export function lintProvider(
1340
1569
  },
1341
1570
  options: ProviderLintOptions = {},
1342
1571
  ): LintDiagnostic[] {
1572
+ return lintProviderWithInformation(provider, options).diagnostics;
1573
+ }
1574
+
1575
+ /** Internal detailed result used by the CLI to render suppression audits. */
1576
+ export function lintProviderWithInformation(
1577
+ provider: Parameters<typeof lintProvider>[0],
1578
+ options: ProviderLintOptions = {},
1579
+ ): ProviderLintResult {
1343
1580
  const diagnostics: LintDiagnostic[] = [
1344
1581
  ...lintAllowedHosts(provider.id, provider.allowedHosts),
1345
1582
  ...lintReviewed(provider.id, provider.reviewed),
@@ -1348,6 +1585,7 @@ export function lintProvider(
1348
1585
  ...lintCredentialWriteUsage(provider),
1349
1586
  ...lintPlaywrightDirectImports(provider),
1350
1587
  ...lintSelfHostedBrowserPatterns(provider, options),
1588
+ ...lintBrowserVersionLiterals(provider),
1351
1589
  ...lintUndeclaredThrownErrorCodes(provider),
1352
1590
  ];
1353
1591
 
@@ -1375,7 +1613,7 @@ export function lintProvider(
1375
1613
  }
1376
1614
 
1377
1615
  if (!provider.operations) {
1378
- return diagnostics;
1616
+ return applyPinnedWireFieldPaths(provider, diagnostics);
1379
1617
  }
1380
1618
 
1381
1619
  diagnostics.push(
@@ -1411,5 +1649,5 @@ export function lintProvider(
1411
1649
  ),
1412
1650
  );
1413
1651
 
1414
- return diagnostics;
1652
+ return applyPinnedWireFieldPaths(provider, diagnostics);
1415
1653
  }
@@ -27,7 +27,7 @@ const DNS_WARN_MS = 5;
27
27
  const BROWSER_IDLE_MS = 5_000;
28
28
  const REFRESH_WARN_RATE = 0.1;
29
29
 
30
- const TLS_REUSE_FIX = `const session = ctx.stealth.createSession({ profile: 'chrome-146' });
30
+ const TLS_REUSE_FIX = `const session = ctx.stealth.createSession({ profile: 'chrome-desktop' });
31
31
  const resp = await session.fetch(url, opts);`;
32
32
 
33
33
  const TRANSFORM_FIX = `transformResponse: (raw) => {
@@ -39,7 +39,7 @@ const LARGE_RESPONSE_FIX = `const resp = await ctx.http.get('/items', {
39
39
  });`;
40
40
 
41
41
  const DNS_FIX = `// Enable DNS caching or reuse a long-lived session per host.
42
- const session = ctx.stealth.createSession({ profile: 'chrome-146' });
42
+ const session = ctx.stealth.createSession({ profile: 'chrome-desktop' });
43
43
  await session.fetch(url, opts);`;
44
44
 
45
45
  const PROXY_FIX = `// Re-check whether this operation really needs a proxy.