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

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 (231) hide show
  1. package/AUTHORING.md +330 -8
  2. package/CHANGELOG.md +89 -1
  3. package/README.md +64 -17
  4. package/SUBMISSION.md +86 -0
  5. package/bin/apifuse-check.ts +60 -6
  6. package/bin/apifuse-dev.ts +58 -8
  7. package/bin/apifuse-pack-check.ts +32 -2
  8. package/bin/apifuse-pack-smoke.ts +133 -6
  9. package/bin/apifuse-perf.ts +142 -49
  10. package/bin/apifuse-record.ts +182 -104
  11. package/bin/apifuse-submit-check.ts +3243 -0
  12. package/bin/apifuse.ts +1 -1
  13. package/dist/auth.d.ts +76 -0
  14. package/dist/auth.js +436 -0
  15. package/dist/ceremonies/index.d.ts +41 -0
  16. package/dist/ceremonies/index.js +490 -0
  17. package/dist/choice-token.d.ts +24 -0
  18. package/dist/choice-token.js +74 -0
  19. package/dist/cli/commands.d.ts +10 -0
  20. package/dist/cli/commands.js +80 -0
  21. package/dist/cli/create.d.ts +47 -0
  22. package/dist/cli/create.js +777 -0
  23. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  24. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  25. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  26. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  27. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  28. package/dist/cli/templates/provider/README.md.tpl +163 -0
  29. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  30. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  31. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  32. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  33. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  34. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  35. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  36. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  37. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  38. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  39. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  40. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  41. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  42. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  43. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  44. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  46. package/dist/config/loader.d.ts +107 -0
  47. package/dist/config/loader.js +935 -0
  48. package/dist/contract-json.d.ts +9 -0
  49. package/dist/contract-json.js +51 -0
  50. package/dist/contract-serialization.d.ts +4 -0
  51. package/dist/contract-serialization.js +78 -0
  52. package/dist/contract-types.d.ts +49 -0
  53. package/dist/contract-types.js +1 -0
  54. package/dist/contract.d.ts +6 -0
  55. package/dist/contract.js +156 -0
  56. package/dist/define.d.ts +100 -0
  57. package/dist/define.js +1383 -0
  58. package/dist/dev.d.ts +9 -0
  59. package/dist/dev.js +15 -0
  60. package/dist/errors.d.ts +59 -0
  61. package/dist/errors.js +97 -0
  62. package/dist/i18n/catalog.d.ts +29 -0
  63. package/dist/i18n/catalog.js +159 -0
  64. package/dist/i18n/index.d.ts +2 -0
  65. package/dist/i18n/index.js +2 -0
  66. package/dist/i18n/keys.d.ts +10 -0
  67. package/dist/i18n/keys.js +34 -0
  68. package/dist/index.d.ts +42 -0
  69. package/dist/index.js +38 -0
  70. package/dist/lint.d.ts +74 -0
  71. package/dist/lint.js +729 -0
  72. package/dist/observability.d.ts +5 -0
  73. package/dist/observability.js +39 -0
  74. package/dist/provider.d.ts +11 -0
  75. package/dist/provider.js +9 -0
  76. package/dist/public-schema-field-lint.d.ts +2 -0
  77. package/dist/public-schema-field-lint.js +158 -0
  78. package/dist/recipes/gov-api.d.ts +19 -0
  79. package/dist/recipes/gov-api.js +72 -0
  80. package/dist/recipes/rest-api.d.ts +21 -0
  81. package/dist/recipes/rest-api.js +115 -0
  82. package/dist/runtime/auth-flow.d.ts +14 -0
  83. package/dist/runtime/auth-flow.js +46 -0
  84. package/dist/runtime/browser.d.ts +25 -0
  85. package/dist/runtime/browser.js +1237 -0
  86. package/dist/runtime/cache.d.ts +10 -0
  87. package/dist/runtime/cache.js +372 -0
  88. package/dist/runtime/choice.d.ts +15 -0
  89. package/dist/runtime/choice.js +435 -0
  90. package/dist/runtime/credential.d.ts +8 -0
  91. package/dist/runtime/credential.js +61 -0
  92. package/dist/runtime/env.d.ts +2 -0
  93. package/dist/runtime/env.js +10 -0
  94. package/dist/runtime/executor.d.ts +16 -0
  95. package/dist/runtime/executor.js +51 -0
  96. package/dist/runtime/http.d.ts +8 -0
  97. package/dist/runtime/http.js +726 -0
  98. package/dist/runtime/insights.d.ts +9 -0
  99. package/dist/runtime/insights.js +324 -0
  100. package/dist/runtime/instrumentation.d.ts +8 -0
  101. package/dist/runtime/instrumentation.js +269 -0
  102. package/dist/runtime/key-derivation.d.ts +24 -0
  103. package/dist/runtime/key-derivation.js +73 -0
  104. package/dist/runtime/keyring.d.ts +25 -0
  105. package/dist/runtime/keyring.js +93 -0
  106. package/dist/runtime/namespace.d.ts +9 -0
  107. package/dist/runtime/namespace.js +19 -0
  108. package/dist/runtime/otlp.d.ts +39 -0
  109. package/dist/runtime/otlp.js +103 -0
  110. package/dist/runtime/perf.d.ts +12 -0
  111. package/dist/runtime/perf.js +52 -0
  112. package/dist/runtime/prevalidate.d.ts +12 -0
  113. package/dist/runtime/prevalidate.js +173 -0
  114. package/dist/runtime/provider.d.ts +2 -0
  115. package/dist/runtime/provider.js +11 -0
  116. package/dist/runtime/proxy-errors.d.ts +21 -0
  117. package/dist/runtime/proxy-errors.js +83 -0
  118. package/dist/runtime/proxy-telemetry.d.ts +8 -0
  119. package/dist/runtime/proxy-telemetry.js +174 -0
  120. package/dist/runtime/redis.d.ts +17 -0
  121. package/dist/runtime/redis.js +82 -0
  122. package/dist/runtime/request-options.d.ts +3 -0
  123. package/dist/runtime/request-options.js +42 -0
  124. package/dist/runtime/state.d.ts +17 -0
  125. package/dist/runtime/state.js +344 -0
  126. package/dist/runtime/stealth.d.ts +21 -0
  127. package/dist/runtime/stealth.js +980 -0
  128. package/dist/runtime/stt.d.ts +22 -0
  129. package/dist/runtime/stt.js +480 -0
  130. package/dist/runtime/trace.d.ts +26 -0
  131. package/dist/runtime/trace.js +142 -0
  132. package/dist/runtime/waterfall.d.ts +12 -0
  133. package/dist/runtime/waterfall.js +147 -0
  134. package/dist/schema.d.ts +74 -0
  135. package/dist/schema.js +243 -0
  136. package/dist/serve.d.ts +1 -0
  137. package/dist/serve.js +1 -0
  138. package/dist/server/index.d.ts +3 -0
  139. package/dist/server/index.js +2 -0
  140. package/dist/server/serve.d.ts +64 -0
  141. package/dist/server/serve.js +1118 -0
  142. package/dist/server/types.d.ts +136 -0
  143. package/dist/server/types.js +86 -0
  144. package/dist/stealth/profiles.d.ts +4 -0
  145. package/dist/stealth/profiles.js +259 -0
  146. package/dist/stream.d.ts +44 -0
  147. package/dist/stream.js +151 -0
  148. package/dist/testing/helpers.d.ts +23 -0
  149. package/dist/testing/helpers.js +95 -0
  150. package/dist/testing/index.d.ts +2 -0
  151. package/dist/testing/index.js +2 -0
  152. package/dist/testing/run.d.ts +34 -0
  153. package/dist/testing/run.js +307 -0
  154. package/dist/types.d.ts +1467 -0
  155. package/dist/types.js +61 -0
  156. package/dist/utils/date.d.ts +6 -0
  157. package/dist/utils/date.js +101 -0
  158. package/dist/utils/parse.d.ts +16 -0
  159. package/dist/utils/parse.js +51 -0
  160. package/dist/utils/text.d.ts +4 -0
  161. package/dist/utils/text.js +14 -0
  162. package/dist/utils/transform.d.ts +8 -0
  163. package/dist/utils/transform.js +48 -0
  164. package/package.json +57 -29
  165. package/src/auth.ts +786 -0
  166. package/src/ceremonies/index.ts +8 -2
  167. package/src/choice-token.ts +165 -0
  168. package/src/cli/commands.ts +34 -11
  169. package/src/cli/create.ts +254 -128
  170. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  171. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  172. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  173. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  174. package/src/cli/templates/provider/README.md.tpl +87 -7
  175. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  176. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  177. package/src/cli/templates/provider/index.ts.tpl +5 -47
  178. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  179. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  180. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  181. package/src/cli/templates/provider/operations/ping.ts.tpl +24 -0
  182. package/src/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  183. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  184. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  185. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  186. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  187. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  188. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  189. package/src/cli/templates/provider/start.ts.tpl +1 -1
  190. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  191. package/src/config/loader.ts +1224 -9
  192. package/src/contract-json.ts +75 -0
  193. package/src/contract-serialization.ts +89 -0
  194. package/src/contract-types.ts +52 -0
  195. package/src/contract.ts +216 -0
  196. package/src/define.ts +1820 -70
  197. package/src/errors.ts +27 -0
  198. package/src/i18n/catalog.ts +277 -0
  199. package/src/i18n/index.ts +2 -0
  200. package/src/i18n/keys.ts +64 -0
  201. package/src/index.ts +189 -9
  202. package/src/lint.ts +580 -73
  203. package/src/observability.ts +41 -0
  204. package/src/provider.ts +131 -4
  205. package/src/public-schema-field-lint.ts +237 -0
  206. package/src/runtime/auth-flow.ts +9 -0
  207. package/src/runtime/browser.ts +1054 -51
  208. package/src/runtime/cache.ts +528 -0
  209. package/src/runtime/choice.ts +760 -0
  210. package/src/runtime/executor.ts +32 -3
  211. package/src/runtime/http.ts +980 -195
  212. package/src/runtime/insights.ts +11 -11
  213. package/src/runtime/instrumentation.ts +12 -4
  214. package/src/runtime/key-derivation.ts +1 -1
  215. package/src/runtime/keyring.ts +4 -3
  216. package/src/runtime/proxy-errors.ts +132 -0
  217. package/src/runtime/proxy-telemetry.ts +253 -0
  218. package/src/runtime/redis.ts +116 -0
  219. package/src/runtime/request-options.ts +66 -0
  220. package/src/runtime/state.ts +563 -0
  221. package/src/runtime/stealth.ts +1336 -0
  222. package/src/runtime/stt.ts +629 -0
  223. package/src/runtime/trace.ts +1 -1
  224. package/src/schema.ts +363 -1
  225. package/src/server/serve.ts +1192 -75
  226. package/src/server/types.ts +37 -0
  227. package/src/stream.ts +210 -0
  228. package/src/testing/run.ts +40 -6
  229. package/src/types.ts +1283 -59
  230. package/src/runtime/tls.ts +0 -434
  231. package/src/types/playwright-stealth.d.ts +0 -9
package/src/lint.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { ZodType } from "zod";
2
2
 
3
+ import { lintPublicSchemaFieldNames } from "./public-schema-field-lint";
4
+ import {
5
+ APIFUSE_DESCRIPTION_KEY_META_KEY,
6
+ APIFUSE_SENSITIVE_META_KEY,
7
+ } from "./schema";
8
+
3
9
  type AuthModeLike =
4
10
  | "none"
5
11
  | "platform-managed"
@@ -14,13 +20,23 @@ type ProviderAuthLike = {
14
20
  continue?: unknown;
15
21
  poll?: unknown;
16
22
  abort?: unknown;
23
+ refresh?: unknown;
17
24
  };
25
+ exchange?: unknown;
26
+ };
27
+
28
+ const AUTH_OPERATION_ID_PATTERN =
29
+ /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
30
+
31
+ type ProviderContractMetaLike = {
32
+ publicSchemaFieldNames?: "normalized";
18
33
  };
19
34
 
20
35
  type SchemaLike = ZodType & {
21
36
  description?: string;
22
37
  def?: Record<string, unknown>;
23
38
  _def?: Record<string, unknown>;
39
+ meta?: () => Record<string, unknown> | undefined;
24
40
  shape?: Record<string, SchemaLike> | (() => Record<string, SchemaLike>);
25
41
  element?: SchemaLike;
26
42
  items?: SchemaLike[];
@@ -41,9 +57,21 @@ export interface LintDiagnostic {
41
57
  field?: string;
42
58
  }
43
59
 
60
+ export type ProviderLintMode = "official" | "standalone";
61
+
62
+ type ProviderLintOptions = {
63
+ mode?: ProviderLintMode;
64
+ };
65
+
66
+ type ProviderSourceLike = {
67
+ authFlowSource?: string;
68
+ providerSourceFiles?: Record<string, string>;
69
+ operations?: Record<string, { handler?: unknown; source?: string }>;
70
+ };
71
+
44
72
  function lintAllowedHosts(
45
73
  providerId: string | undefined,
46
- allowedHosts: string[] | undefined,
74
+ allowedHosts: readonly string[] | undefined,
47
75
  ): LintDiagnostic[] {
48
76
  const prefix = providerId ? `Provider "${providerId}"` : "Provider";
49
77
 
@@ -103,7 +131,11 @@ function lintReviewed(
103
131
  ];
104
132
  }
105
133
 
106
- function hasReusableSecretKeys(keys: string[] | undefined): boolean {
134
+ function isProviderAuthLike(value: unknown): value is ProviderAuthLike {
135
+ return !!value && typeof value === "object" && !Array.isArray(value);
136
+ }
137
+
138
+ function hasReusableSecretKeys(keys: readonly string[] | undefined): boolean {
107
139
  if (!keys) {
108
140
  return false;
109
141
  }
@@ -115,6 +147,18 @@ function hasReusableSecretKeys(keys: string[] | undefined): boolean {
115
147
  );
116
148
  }
117
149
 
150
+ function hasReusableReloginSecretKeys(
151
+ keys: readonly string[] | undefined,
152
+ ): boolean {
153
+ if (!keys) {
154
+ return false;
155
+ }
156
+
157
+ return keys.some((key) =>
158
+ /(password|passcode|secret|cookie|session)/i.test(key),
159
+ );
160
+ }
161
+
118
162
  function getAuthFlowSource(provider: {
119
163
  auth?: ProviderAuthLike;
120
164
  authFlowSource?: string;
@@ -128,6 +172,7 @@ function getAuthFlowSource(provider: {
128
172
  provider.auth?.flow?.continue,
129
173
  provider.auth?.flow?.poll,
130
174
  provider.auth?.flow?.abort,
175
+ provider.auth?.flow?.refresh,
131
176
  ];
132
177
 
133
178
  return parts
@@ -143,12 +188,12 @@ function lintAuthModel(provider: {
143
188
  id?: string;
144
189
  auth?: ProviderAuthLike;
145
190
  credential?: {
146
- keys?: string[];
191
+ keys?: readonly string[];
147
192
  storesReusableSecret?: boolean;
148
193
  justification?: string;
149
194
  };
150
195
  context?: {
151
- keys?: string[];
196
+ keys?: readonly string[];
152
197
  };
153
198
  authFlowSource?: string;
154
199
  }): LintDiagnostic[] {
@@ -178,6 +223,15 @@ function lintAuthModel(provider: {
178
223
  });
179
224
  }
180
225
 
226
+ if (isProviderAuthLike(provider.auth) && "exchange" in provider.auth) {
227
+ diagnostics.push({
228
+ rule: "auth-exchange-unsupported",
229
+ level: "error",
230
+ field: "auth.exchange",
231
+ message: `${providerLabel} must not define auth.exchange. The Provider SDK has one auth interface: auth.flow. Gateway only calls auth.flow.start/continue/poll/abort/refresh and persists complete turn data.credential as-is; put login/token/session exchange inside auth.flow.continue.`,
232
+ });
233
+ }
234
+
181
235
  if (authMode === "credentials" && credentialKeys.length === 0) {
182
236
  diagnostics.push({
183
237
  rule: "credential-keys-required-when-credentials-mode",
@@ -200,6 +254,20 @@ function lintAuthModel(provider: {
200
254
  });
201
255
  }
202
256
 
257
+ if (
258
+ typeof provider.auth?.flow?.refresh === "function" &&
259
+ hasReusableReloginSecretKeys(credentialKeys) &&
260
+ (!provider.credential?.storesReusableSecret ||
261
+ !provider.credential.justification)
262
+ ) {
263
+ diagnostics.push({
264
+ rule: "auth-refresh-reusable-secret",
265
+ level: "error",
266
+ field: "credential",
267
+ message: `${providerLabel} must set storesReusableSecret and justification when auth.flow.refresh may silently re-login with reusable credential secrets.`,
268
+ });
269
+ }
270
+
203
271
  if (authMode === "platform-managed" && credentialKeys.length > 0) {
204
272
  diagnostics.push({
205
273
  rule: "platform-managed-no-credential-keys",
@@ -359,7 +427,60 @@ function getChildSchemas(
359
427
  }));
360
428
  }
361
429
 
362
- function collectMissingDescriptions(
430
+ function uniqueFields(fields: string[]): string[] {
431
+ return Array.from(new Set(fields));
432
+ }
433
+
434
+ function isSensitiveSchema(schema: unknown): boolean {
435
+ if (!schema || typeof schema !== "object" || !("meta" in schema)) {
436
+ return false;
437
+ }
438
+ const meta = schema.meta;
439
+ if (typeof meta !== "function") return false;
440
+ const metadata = meta.call(schema);
441
+ return (
442
+ !!metadata &&
443
+ typeof metadata === "object" &&
444
+ Reflect.get(metadata, APIFUSE_SENSITIVE_META_KEY) === true
445
+ );
446
+ }
447
+
448
+ function getSchemaMetadata(schema: SchemaLike): Record<string, unknown> {
449
+ return schema.meta?.() ?? {};
450
+ }
451
+
452
+ function getSchemaDescriptionKey(schema: SchemaLike): string | undefined {
453
+ const value = Reflect.get(
454
+ getSchemaMetadata(schema),
455
+ APIFUSE_DESCRIPTION_KEY_META_KEY,
456
+ );
457
+ return typeof value === "string" && value.length > 0 ? value : undefined;
458
+ }
459
+
460
+ const SENSITIVE_FIELD_NAMES = new Set([
461
+ "apikey",
462
+ "authorization",
463
+ "cookie",
464
+ "secret",
465
+ "secrets",
466
+ "token",
467
+ "accesstoken",
468
+ "refreshtoken",
469
+ "password",
470
+ "passwd",
471
+ "otp",
472
+ "otpcode",
473
+ "phone",
474
+ "phonenumber",
475
+ "paymenturl",
476
+ ]);
477
+
478
+ function isSensitiveFieldName(name: string): boolean {
479
+ const normalized = name.toLowerCase().replace(/[-_\s]/g, "");
480
+ return SENSITIVE_FIELD_NAMES.has(normalized);
481
+ }
482
+
483
+ function collectUnmarkedSensitiveFields(
363
484
  schema: unknown,
364
485
  basePath: string,
365
486
  seen = new Set<SchemaLike>(),
@@ -367,13 +488,80 @@ function collectMissingDescriptions(
367
488
  if (!isSchema(schema) || seen.has(schema)) {
368
489
  return [];
369
490
  }
491
+ seen.add(schema);
492
+ const out: string[] = [];
493
+ for (const [key, child] of Object.entries(getObjectShape(schema))) {
494
+ const childPath = basePath ? `${basePath}.${key}` : key;
495
+ if (isSensitiveFieldName(key) && !isSensitiveSchema(child)) {
496
+ out.push(childPath);
497
+ }
498
+ out.push(...collectUnmarkedSensitiveFields(child, childPath, seen));
499
+ }
500
+ for (const child of getChildSchemas(schema)) {
501
+ if (Object.hasOwn(getObjectShape(schema), child.key)) continue;
502
+ const isWrapperNode = [
503
+ "unwrap",
504
+ "innerType",
505
+ "sourceType",
506
+ "schema",
507
+ "type",
508
+ "in",
509
+ "out",
510
+ "option",
511
+ "pipe",
512
+ "payload",
513
+ "item",
514
+ "rest",
515
+ "catchall",
516
+ "keyType",
517
+ "valueType",
518
+ ].includes(child.key);
519
+ const childPath =
520
+ child.key === "element" || child.key.startsWith("element.")
521
+ ? `${basePath}[]`
522
+ : isWrapperNode || child.key.startsWith("pipe.")
523
+ ? basePath
524
+ : basePath
525
+ ? `${basePath}.${child.key}`
526
+ : child.key;
527
+ out.push(...collectUnmarkedSensitiveFields(child.schema, childPath, seen));
528
+ }
529
+ return out;
530
+ }
531
+
532
+ function collectSchemaDescriptionKeyDiagnostics(
533
+ schema: unknown,
534
+ basePath: string,
535
+ seen = new Set<SchemaLike>(),
536
+ requireCurrentDescription = true,
537
+ ): LintDiagnostic[] {
538
+ if (!isSchema(schema) || seen.has(schema)) {
539
+ return [];
540
+ }
370
541
 
371
542
  seen.add(schema);
372
- const missing: string[] = [];
543
+ const diagnostics: LintDiagnostic[] = [];
373
544
  const currentPath = basePath || "schema";
545
+ const hasDescriptionKey = getSchemaDescriptionKey(schema) !== undefined;
546
+
547
+ if (schema.description && !hasDescriptionKey) {
548
+ diagnostics.push({
549
+ rule: "schema-description-raw-prose",
550
+ level: "error",
551
+ field: currentPath,
552
+ message: `Schema field "${currentPath}" must use .describeKey() or describeKey() instead of raw static prose.`,
553
+ });
554
+ }
374
555
 
375
- if (!schema.description) {
376
- missing.push(currentPath);
556
+ if (requireCurrentDescription && !hasDescriptionKey) {
557
+ diagnostics.push({
558
+ rule: "schema-description-key-required",
559
+ level: "error",
560
+ field: currentPath,
561
+ message: schema.description
562
+ ? `Schema field "${currentPath}" has a raw description but is missing .describeKey() or describeKey() metadata.`
563
+ : `Schema field "${currentPath}" is missing .describeKey() or describeKey() metadata.`,
564
+ });
377
565
  }
378
566
 
379
567
  for (const child of getChildSchemas(schema)) {
@@ -383,30 +571,42 @@ function collectMissingDescriptions(
383
571
  "sourceType",
384
572
  "schema",
385
573
  "type",
574
+ "in",
575
+ "out",
386
576
  "option",
387
577
  "pipe",
388
578
  "payload",
389
579
  "item",
390
580
  "rest",
391
581
  "catchall",
582
+ "keyType",
583
+ "valueType",
392
584
  ].includes(child.key);
585
+ const isStructuralNode =
586
+ isWrapperNode ||
587
+ child.key.startsWith("pipe.") ||
588
+ child.key === "element" ||
589
+ child.key.startsWith("element.");
393
590
  const childPath = isWrapperNode
394
591
  ? currentPath
395
592
  : currentPath === "schema"
396
593
  ? child.key
397
594
  : /^\d+$/.test(child.key)
398
595
  ? `${currentPath}[${child.key}]`
399
- : child.key.startsWith("element")
596
+ : child.key === "element" || child.key.startsWith("element.")
400
597
  ? `${currentPath}[]`
401
598
  : `${currentPath}.${child.key}`;
402
- missing.push(...collectMissingDescriptions(child.schema, childPath, seen));
599
+ diagnostics.push(
600
+ ...collectSchemaDescriptionKeyDiagnostics(
601
+ child.schema,
602
+ childPath,
603
+ seen,
604
+ !isStructuralNode,
605
+ ),
606
+ );
403
607
  }
404
608
 
405
- return missing;
406
- }
407
-
408
- function uniqueFields(fields: string[]): string[] {
409
- return Array.from(new Set(fields));
609
+ return diagnostics;
410
610
  }
411
611
 
412
612
  function isComplexSchema(
@@ -438,60 +638,296 @@ function hasBidirectionalFixtures(fixtures: unknown): boolean {
438
638
  return "request" in fixtures && "response" in fixtures;
439
639
  }
440
640
 
641
+ function getOperationSource(operation: {
642
+ handler?: unknown;
643
+ source?: string;
644
+ }): string {
645
+ if (operation.source) {
646
+ return operation.source;
647
+ }
648
+ return typeof operation.handler === "function"
649
+ ? operation.handler.toString()
650
+ : "";
651
+ }
652
+
653
+ function lintStealthTransportUsage(provider: {
654
+ id?: string;
655
+ stealth?: unknown;
656
+ operations?: Record<string, { handler?: unknown; source?: string }>;
657
+ }): LintDiagnostic[] {
658
+ if (provider.stealth || !provider.operations) {
659
+ return [];
660
+ }
661
+
662
+ const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
663
+ return Object.entries(provider.operations).flatMap(
664
+ ([operationKey, operation]) => {
665
+ const source = getOperationSource(operation);
666
+ if (!/\bctx\.stealth\b/.test(source)) {
667
+ return [];
668
+ }
669
+ return [
670
+ {
671
+ rule: "stealth-config-required",
672
+ level: "error" as const,
673
+ field: `operations.${operationKey}`,
674
+ message: `${providerLabel} operation "${operationKey}" uses ctx.stealth but provider.stealth is not declared.`,
675
+ },
676
+ ];
677
+ },
678
+ );
679
+ }
680
+
681
+ function lintCredentialWriteUsage(provider: {
682
+ operations?: Record<string, { handler?: unknown; source?: string }>;
683
+ }): LintDiagnostic[] {
684
+ if (!provider.operations) {
685
+ return [];
686
+ }
687
+
688
+ return Object.entries(provider.operations).flatMap(
689
+ ([operationKey, operation]) => {
690
+ const source = getOperationSource(operation);
691
+ if (!/\bctx\.credential\.(?:set|setMany)\s*\(/.test(source)) {
692
+ return [];
693
+ }
694
+
695
+ return [
696
+ {
697
+ rule: "ctx-credential-write-forbidden-in-handler",
698
+ level: "error" as const,
699
+ field: `operations.${operationKey}.handler`,
700
+ message:
701
+ "Operation handlers must not mutate credentials; return refreshed credentials from auth.flow.refresh instead.",
702
+ },
703
+ ];
704
+ },
705
+ );
706
+ }
707
+
708
+ function lintPlaywrightDirectImports(provider: {
709
+ authFlowSource?: string;
710
+ providerSourceFiles?: Record<string, string>;
711
+ operations?: Record<string, { handler?: unknown; source?: string }>;
712
+ }): LintDiagnostic[] {
713
+ const diagnostics: LintDiagnostic[] = [];
714
+ const importPattern =
715
+ /(?:import\s+(?:type\s+)?[\s\S]*?\s+from\s+["'](?:playwright|playwright-core)["']|require\(\s*["'](?:playwright|playwright-core)["']\s*\)|import\(\s*["'](?:playwright|playwright-core)["']\s*\))/;
716
+
717
+ if (provider.authFlowSource && importPattern.test(provider.authFlowSource)) {
718
+ diagnostics.push({
719
+ rule: "playwright-direct-import",
720
+ level: "warn",
721
+ field: "auth.flow",
722
+ message:
723
+ "Provider auth flow imports playwright directly; use ctx.browser frame-aware methods so the SDK can enforce the CDP pool runtime.",
724
+ });
725
+ }
726
+
727
+ for (const [filePath, source] of Object.entries(
728
+ provider.providerSourceFiles ?? {},
729
+ )) {
730
+ if (!importPattern.test(source)) {
731
+ continue;
732
+ }
733
+
734
+ diagnostics.push({
735
+ rule: "playwright-direct-import",
736
+ level: "warn",
737
+ field: `sourceFiles.${filePath}`,
738
+ message:
739
+ "Provider source imports playwright directly; use ctx.browser frame-aware methods so the SDK can enforce the CDP pool runtime.",
740
+ });
741
+ }
742
+
743
+ if (!provider.operations) {
744
+ return diagnostics;
745
+ }
746
+
747
+ for (const [operationKey, operation] of Object.entries(provider.operations)) {
748
+ const source = getOperationSource(operation);
749
+ if (!importPattern.test(source)) {
750
+ continue;
751
+ }
752
+
753
+ diagnostics.push({
754
+ rule: "playwright-direct-import",
755
+ level: "warn",
756
+ field: `operations.${operationKey}.handler`,
757
+ message:
758
+ "Operation source imports playwright directly; use ctx.browser frame-aware methods so the SDK can enforce the CDP pool runtime.",
759
+ });
760
+ }
761
+
762
+ return diagnostics;
763
+ }
764
+
765
+ type SelfHostedBrowserPattern = {
766
+ rule: string;
767
+ pattern: RegExp;
768
+ message: string;
769
+ };
770
+
771
+ const SELF_HOSTED_BROWSER_MESSAGE =
772
+ "Official browser providers must use ctx.browser backed by the managed CDP Pool; do not launch or connect to provider-local Chrome/CDP runtimes.";
773
+
774
+ const SELF_HOSTED_BROWSER_PATTERNS: readonly SelfHostedBrowserPattern[] = [
775
+ {
776
+ rule: "browser-self-hosted-launch",
777
+ pattern: /\b(?:playwright|chromium|firefox|webkit|puppeteer)\.launch\s*\(/,
778
+ message: `${SELF_HOSTED_BROWSER_MESSAGE} Replace direct Playwright/Puppeteer launch calls with ctx.browser.newPage() or ctx.browser.withIsolatedContext().`,
779
+ },
780
+ {
781
+ rule: "browser-self-hosted-child-process",
782
+ pattern:
783
+ /(?:\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\([\s\S]{0,240}\b(?:google-chrome|chrome|chromium|chromium-browser)\b|\b(?:Bun\.)?spawn(?:Sync)?\s*\([\s\S]{0,240}\b(?:google-chrome|chrome|chromium|chromium-browser)\b|\$`[\s\S]{0,240}\b(?:google-chrome|chrome|chromium|chromium-browser)\b)/,
784
+ message: `${SELF_HOSTED_BROWSER_MESSAGE} Provider pods must not start Chrome with child_process, Bun.spawn, or shell commands.`,
785
+ },
786
+ {
787
+ rule: "browser-self-hosted-remote-debugging-port",
788
+ pattern:
789
+ /(?:\b(?:google-chrome|chrome|chromium|chromium-browser)\b[\s\S]{0,240}--remote-debugging-port\b|--remote-debugging-port(?:=|\s+))/,
790
+ message: `${SELF_HOSTED_BROWSER_MESSAGE} Provider entrypoints, Dockerfiles, and scripts must not start Chrome with a remote debugging port; use the managed CDP Pool instead.`,
791
+ },
792
+ {
793
+ rule: "browser-direct-cdp-version-poll",
794
+ pattern: /\/json\/version\b/,
795
+ message: `${SELF_HOSTED_BROWSER_MESSAGE} Do not poll /json/version from provider code; the SDK manages CDP leases through APIFUSE__CDP_POOL__URL.`,
796
+ },
797
+ {
798
+ rule: "browser-provider-local-cdp-env",
799
+ pattern:
800
+ /\b(?!APIFUSE__CDP_POOL__URL\b)[A-Z][A-Z0-9_]*_CDP_URL\b|process\.env(?:\.(?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL\b|\[\s*["'`](?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL["'`]\s*\])/,
801
+ message: `${SELF_HOSTED_BROWSER_MESSAGE} Do not read provider-local CDP endpoint env vars including AMAZON_CDP_URL or custom *_CDP_URL names; production uses APIFUSE__CDP_POOL__URL through ctx.browser.`,
802
+ },
803
+ ];
804
+
805
+ function lintSelfHostedBrowserPatterns(
806
+ provider: ProviderSourceLike,
807
+ options: ProviderLintOptions,
808
+ ): LintDiagnostic[] {
809
+ const diagnostics: LintDiagnostic[] = [];
810
+ const level = options.mode === "standalone" ? "warn" : "error";
811
+ const sources: Array<{ field: string; source: string }> = [];
812
+
813
+ if (provider.authFlowSource) {
814
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
815
+ }
816
+
817
+ for (const [filePath, source] of Object.entries(
818
+ provider.providerSourceFiles ?? {},
819
+ )) {
820
+ sources.push({ field: `sourceFiles.${filePath}`, source });
821
+ }
822
+
823
+ for (const [operationKey, operation] of Object.entries(
824
+ provider.operations ?? {},
825
+ )) {
826
+ const source = getOperationSource(operation);
827
+ if (source) {
828
+ sources.push({
829
+ field: `operations.${operationKey}.handler`,
830
+ source,
831
+ });
832
+ }
833
+ }
834
+
835
+ for (const { field, source } of sources) {
836
+ for (const item of SELF_HOSTED_BROWSER_PATTERNS) {
837
+ item.pattern.lastIndex = 0;
838
+ if (!item.pattern.test(source)) {
839
+ continue;
840
+ }
841
+ diagnostics.push({
842
+ rule: item.rule,
843
+ level,
844
+ field,
845
+ message: item.message,
846
+ });
847
+ }
848
+ }
849
+
850
+ return diagnostics;
851
+ }
852
+
441
853
  export function lintOperation(op: {
442
- description: string;
854
+ description?: string;
855
+ descriptionKey?: string;
856
+ whenToUse?: readonly string[];
857
+ whenToUseKeys?: readonly string[];
858
+ whenNotToUse?: readonly string[];
859
+ whenNotToUseKeys?: readonly string[];
443
860
  input: unknown;
444
861
  output: unknown;
445
862
  fixtures?: unknown;
446
- inputExamples?: unknown[];
863
+ inputExamples?: readonly unknown[];
447
864
  derivations?: Record<string, string>;
448
865
  }): LintDiagnostic[] {
449
866
  const diagnostics: LintDiagnostic[] = [];
450
867
  const description = op.description ?? "";
868
+ const hasDescriptionKey =
869
+ typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
451
870
 
452
- if (description.length < 150) {
871
+ if (description.trim().length > 0 && !hasDescriptionKey) {
453
872
  diagnostics.push({
454
- rule: "description-min-length",
873
+ rule: "operation-description-raw-prose",
455
874
  level: "error",
456
875
  field: "description",
457
- message: "Operation description must be at least 150 characters.",
876
+ message:
877
+ "Operation description must use descriptionKey instead of raw static prose.",
458
878
  });
459
879
  }
460
880
 
461
- const lowerDescription = description.toLowerCase();
462
- if (
463
- !(lowerDescription.includes("use") && lowerDescription.includes("when"))
464
- ) {
881
+ if (!hasDescriptionKey && description.length < 150) {
465
882
  diagnostics.push({
466
- rule: "description-has-when-clause",
467
- level: "warn",
883
+ rule: "description-min-length",
884
+ level: "error",
468
885
  field: "description",
469
- message: 'Operation description should include both "use" and "when".',
886
+ message: "Operation description must be at least 150 characters.",
470
887
  });
471
888
  }
472
889
 
473
- for (const field of uniqueFields(
474
- collectMissingDescriptions(op.input, "input"),
475
- )) {
890
+ if ((op.whenToUse?.length ?? 0) > 0 && !(op.whenToUseKeys?.length ?? 0)) {
476
891
  diagnostics.push({
477
- rule: "all-fields-described",
892
+ rule: "operation-when-to-use-raw-prose",
478
893
  level: "error",
479
- field,
480
- message: `Schema field "${field}" is missing a description.`,
894
+ field: "whenToUse",
895
+ message:
896
+ "Operation whenToUse must use whenToUseKeys instead of raw static prose.",
481
897
  });
482
898
  }
483
899
 
484
- for (const field of uniqueFields(
485
- collectMissingDescriptions(op.output, "output"),
486
- )) {
900
+ if (
901
+ (op.whenNotToUse?.length ?? 0) > 0 &&
902
+ !(op.whenNotToUseKeys?.length ?? 0)
903
+ ) {
487
904
  diagnostics.push({
488
- rule: "all-fields-described",
905
+ rule: "operation-when-not-to-use-raw-prose",
489
906
  level: "error",
490
- field,
491
- message: `Schema field "${field}" is missing a description.`,
907
+ field: "whenNotToUse",
908
+ message:
909
+ "Operation whenNotToUse must use whenNotToUseKeys instead of raw static prose.",
910
+ });
911
+ }
912
+
913
+ const lowerDescription = description.toLowerCase();
914
+ if (
915
+ !hasDescriptionKey &&
916
+ !(lowerDescription.includes("use") && lowerDescription.includes("when"))
917
+ ) {
918
+ diagnostics.push({
919
+ rule: "description-has-when-clause",
920
+ level: "warn",
921
+ field: "description",
922
+ message: 'Operation description should include both "use" and "when".',
492
923
  });
493
924
  }
494
925
 
926
+ diagnostics.push(
927
+ ...collectSchemaDescriptionKeyDiagnostics(op.input, "input"),
928
+ ...collectSchemaDescriptionKeyDiagnostics(op.output, "output"),
929
+ );
930
+
495
931
  if (!hasBidirectionalFixtures(op.fixtures)) {
496
932
  diagnostics.push({
497
933
  rule: "fixtures-both-directions",
@@ -511,41 +947,98 @@ export function lintOperation(op: {
511
947
  });
512
948
  }
513
949
 
950
+ for (const field of uniqueFields(
951
+ collectUnmarkedSensitiveFields(op.input, "input"),
952
+ )) {
953
+ diagnostics.push({
954
+ rule: "sensitive-field-unmarked",
955
+ level: "warn",
956
+ field,
957
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
958
+ });
959
+ }
960
+
961
+ for (const field of uniqueFields(
962
+ collectUnmarkedSensitiveFields(op.output, "output"),
963
+ )) {
964
+ diagnostics.push({
965
+ rule: "sensitive-field-unmarked",
966
+ level: "warn",
967
+ field,
968
+ message: `Schema field "${field}" looks sensitive; mark it with fields.*(), field(..., { sensitive: true }), or sensitive(...).`,
969
+ });
970
+ }
971
+
514
972
  return diagnostics;
515
973
  }
516
974
 
517
- export function lintProvider(provider: {
518
- id?: string;
519
- allowedHosts?: string[];
520
- auth?: ProviderAuthLike;
521
- credential?: {
522
- keys?: string[];
523
- storesReusableSecret?: boolean;
524
- justification?: string;
525
- };
526
- context?: {
527
- keys?: string[];
528
- };
529
- authFlowSource?: string;
530
- operations?: Record<
531
- string,
532
- {
533
- description?: string;
534
- input: unknown;
535
- output: unknown;
536
- fixtures?: unknown;
537
- inputExamples?: unknown[];
538
- derivations?: Record<string, string>;
539
- }
540
- >;
541
- reviewed?: string;
542
- }): LintDiagnostic[] {
975
+ export function lintProvider(
976
+ provider: {
977
+ id?: string;
978
+ allowedHosts?: readonly string[];
979
+ stealth?: unknown;
980
+ auth?: ProviderAuthLike;
981
+ credential?: {
982
+ keys?: readonly string[];
983
+ storesReusableSecret?: boolean;
984
+ justification?: string;
985
+ };
986
+ context?: {
987
+ keys?: readonly string[];
988
+ };
989
+ authFlowSource?: string;
990
+ providerSourceFiles?: Record<string, string>;
991
+ operations?: Record<
992
+ string,
993
+ {
994
+ description?: string;
995
+ descriptionKey?: string;
996
+ whenToUse?: readonly string[];
997
+ whenToUseKeys?: readonly string[];
998
+ whenNotToUse?: readonly string[];
999
+ whenNotToUseKeys?: readonly string[];
1000
+ input: unknown;
1001
+ output: unknown;
1002
+ fixtures?: unknown;
1003
+ inputExamples?: readonly unknown[];
1004
+ derivations?: Record<string, string>;
1005
+ handler?: unknown;
1006
+ source?: string;
1007
+ }
1008
+ >;
1009
+ meta?: {
1010
+ contract?: ProviderContractMetaLike;
1011
+ };
1012
+ reviewed?: string;
1013
+ },
1014
+ options: ProviderLintOptions = {},
1015
+ ): LintDiagnostic[] {
543
1016
  const diagnostics: LintDiagnostic[] = [
544
1017
  ...lintAllowedHosts(provider.id, provider.allowedHosts),
545
1018
  ...lintReviewed(provider.id, provider.reviewed),
546
1019
  ...lintAuthModel(provider),
1020
+ ...lintStealthTransportUsage(provider),
1021
+ ...lintCredentialWriteUsage(provider),
1022
+ ...lintPlaywrightDirectImports(provider),
1023
+ ...lintSelfHostedBrowserPatterns(provider, options),
547
1024
  ];
548
1025
 
1026
+ if (provider.operations) {
1027
+ const authMode = provider.auth?.mode;
1028
+ if (authMode === "credentials" || authMode === "oauth2") {
1029
+ for (const operationKey of Object.keys(provider.operations)) {
1030
+ if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1031
+ diagnostics.push({
1032
+ rule: "auth-operation-unsupported",
1033
+ level: "error",
1034
+ field: `operations.${operationKey}`,
1035
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
549
1042
  if (!provider.operations) {
550
1043
  return diagnostics;
551
1044
  }
@@ -553,14 +1046,28 @@ export function lintProvider(provider: {
553
1046
  diagnostics.push(
554
1047
  ...Object.entries(provider.operations).flatMap(
555
1048
  ([operationKey, operation]) =>
556
- lintOperation({
557
- description: operation.description ?? "",
558
- input: operation.input,
559
- output: operation.output,
560
- fixtures: operation.fixtures,
561
- inputExamples: operation.inputExamples,
562
- derivations: operation.derivations,
563
- }).map((diagnostic) => ({
1049
+ [
1050
+ ...lintOperation({
1051
+ description: operation.description ?? "",
1052
+ descriptionKey: operation.descriptionKey,
1053
+ whenToUse: operation.whenToUse,
1054
+ whenToUseKeys: operation.whenToUseKeys,
1055
+ whenNotToUse: operation.whenNotToUse,
1056
+ whenNotToUseKeys: operation.whenNotToUseKeys,
1057
+ input: operation.input,
1058
+ output: operation.output,
1059
+ fixtures: operation.fixtures,
1060
+ inputExamples: operation.inputExamples,
1061
+ derivations: operation.derivations,
1062
+ }),
1063
+ ...lintPublicSchemaFieldNames(
1064
+ provider.id,
1065
+ operationKey,
1066
+ operation.input,
1067
+ operation.output,
1068
+ provider.meta?.contract?.publicSchemaFieldNames === "normalized",
1069
+ ),
1070
+ ].map((diagnostic) => ({
564
1071
  ...diagnostic,
565
1072
  field: diagnostic.field
566
1073
  ? `operations.${operationKey}.${diagnostic.field}`