@apifuse/provider-sdk 2.2.0-beta.20 → 2.2.0-beta.22

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.
@@ -26,6 +26,15 @@ const validateAuthTurn = ajv.compile(AUTH_TURN_SCHEMA);
26
26
 
27
27
  const OAUTH2_STATE_KEY = "__oauth2_state";
28
28
  const OAUTH2_PKCE_VERIFIER_KEY = "__oauth2_pkce_verifier";
29
+ export const OAUTH2_PROXIED_AUTH_PROXY_ORIGIN_ENV_KEY = "APIFUSE__AUTH_PROXY__URL";
30
+ export const OAUTH2_PROXIED_PKCE_VERIFIER_KEY = "__oauth2_proxied_pkce_verifier";
31
+ const OAUTH2_PROXIED_RESERVED_AUTHORIZE_PARAMS = new Set([
32
+ "client_id",
33
+ "response_type",
34
+ "state",
35
+ "code_challenge",
36
+ "code_challenge_method",
37
+ ]);
29
38
  const DEVICE_FLOW_KEY = "__device_flow";
30
39
  const MAGIC_LINK_KEY = "__magic_link";
31
40
  const COMBINED_STAGE_KEY = "__combined_stage";
@@ -248,6 +257,76 @@ export function createOAuth2Ceremony(options: {
248
257
  };
249
258
  }
250
259
 
260
+ /**
261
+ * Builds the start handler for a custom-scheme OAuth provider. The shared
262
+ * auth-proxy owns state minting and callback capture, while the provider owns
263
+ * token exchange and any following authentication turns.
264
+ */
265
+ export function createOAuth2ProxiedStart(
266
+ options: import("../types.js").ProxiedOAuthConfig,
267
+ ): import("../types.js").AuthFlowStartHandler {
268
+ const pkce = options.pkce ?? "S256";
269
+
270
+ return (ctx) =>
271
+ runCeremonyHandler(
272
+ async () => {
273
+ for (const key of Object.keys(options.authorizeParams ?? {})) {
274
+ if (OAUTH2_PROXIED_RESERVED_AUTHORIZE_PARAMS.has(key.toLowerCase())) {
275
+ throw new ValidationError(
276
+ `OAuth2 proxied authorizeParams cannot override reserved parameter "${key}".`,
277
+ );
278
+ }
279
+ }
280
+ const proxyOrigin = ctx.env.get(OAUTH2_PROXIED_AUTH_PROXY_ORIGIN_ENV_KEY);
281
+ if (!proxyOrigin) {
282
+ throw new ProviderSecretError(
283
+ `Missing required platform environment: ${OAUTH2_PROXIED_AUTH_PROXY_ORIGIN_ENV_KEY}`,
284
+ );
285
+ }
286
+ const clientId = getRequiredEnv(ctx, options.clientIdEnvKey);
287
+ if (!ctx.flowId) {
288
+ throw new ValidationError(
289
+ "OAuth2 proxied start requires the gateway flow id.",
290
+ );
291
+ }
292
+
293
+ const authorizePath = new URL(options.authorizeUrl).pathname;
294
+ const url = new URL(
295
+ `/p/${encodeURIComponent(ctx.providerId)}/f/${encodeURIComponent(ctx.flowId)}${authorizePath}`,
296
+ proxyOrigin,
297
+ );
298
+ url.searchParams.set("client_id", clientId);
299
+ url.searchParams.set("response_type", "code");
300
+
301
+ if (pkce === "S256") {
302
+ const verifier = createCodeVerifier();
303
+ ctx.context.set(OAUTH2_PROXIED_PKCE_VERIFIER_KEY, verifier);
304
+ url.searchParams.set("code_challenge", createCodeChallenge(verifier));
305
+ url.searchParams.set("code_challenge_method", "S256");
306
+ }
307
+
308
+ for (const [key, value] of Object.entries(options.authorizeParams ?? {})) {
309
+ url.searchParams.set(key, value);
310
+ }
311
+
312
+ return createTurn("redirect", {
313
+ data: { url: url.toString() },
314
+ hint: "Open the provider authorization page to continue.",
315
+ expectedInput: {
316
+ type: "object",
317
+ required: ["code", "state"],
318
+ properties: {
319
+ code: { type: "string" },
320
+ state: { type: "string" },
321
+ },
322
+ },
323
+ });
324
+ },
325
+ "OAuth2 proxied start failed",
326
+ ctx,
327
+ );
328
+ }
329
+
251
330
  export function createDeviceFlowCeremony(options: {
252
331
  deviceCodeUrl: string;
253
332
  tokenUrl: string;
package/src/define.ts CHANGED
@@ -84,7 +84,36 @@ interface ProviderImplementationProfile {
84
84
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
85
85
  const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
86
86
  const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
87
- const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"] as const;
87
+ const VALID_AUTH_MODES = [
88
+ "none",
89
+ "platform-managed",
90
+ "credentials",
91
+ "oauth2",
92
+ "oauth2_proxied",
93
+ ] as const;
94
+ const PROXIED_OAUTH_REQUIRED_FIELDS = [
95
+ "authorizeUrl",
96
+ "tokenUrl",
97
+ "customScheme",
98
+ "rewriteProfile",
99
+ "clientIdEnvKey",
100
+ ] as const;
101
+ const PROXIED_OAUTH_ALLOWED_FIELDS = new Set([
102
+ ...PROXIED_OAUTH_REQUIRED_FIELDS,
103
+ "pkce",
104
+ "authorizeParams",
105
+ "tokenParams",
106
+ ]);
107
+ const PROXIED_OAUTH_RESERVED_AUTHORIZE_PARAMS = new Set([
108
+ "client_id",
109
+ "response_type",
110
+ "state",
111
+ "code_challenge",
112
+ "code_challenge_method",
113
+ ]);
114
+ const PROXIED_OAUTH_PROFILE_REGEX = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
115
+ const PROXIED_OAUTH_ENV_KEY_REGEX = /^[A-Z][A-Z0-9_]*__[A-Z0-9_]+$/;
116
+ const CUSTOM_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/\S+$/;
88
117
  const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"] as const;
89
118
  const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"] as const;
90
119
  const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"] as const;
@@ -303,6 +332,111 @@ function assertLiteralField<TValue extends string>(
303
332
  );
304
333
  }
305
334
  }
335
+
336
+ function validateProxiedOAuthParams(
337
+ value: unknown,
338
+ field: "authorizeParams" | "tokenParams",
339
+ providerId: string,
340
+ ): void {
341
+ if (value === undefined) return;
342
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
343
+ throw new ValidationError(
344
+ `Provider "${providerId}" auth.proxied.${field} must be an object of string values.`,
345
+ );
346
+ }
347
+ for (const [key, paramValue] of Object.entries(value)) {
348
+ if (!key.trim() || typeof paramValue !== "string") {
349
+ throw new ValidationError(
350
+ `Provider "${providerId}" auth.proxied.${field} must contain non-empty keys and string values.`,
351
+ );
352
+ }
353
+ if (
354
+ field === "authorizeParams" &&
355
+ PROXIED_OAUTH_RESERVED_AUTHORIZE_PARAMS.has(key.toLowerCase())
356
+ ) {
357
+ throw new ValidationError(
358
+ `Provider "${providerId}" auth.proxied.authorizeParams cannot override reserved parameter "${key}".`,
359
+ );
360
+ }
361
+ }
362
+ }
363
+
364
+ function validateProxiedOAuthAuth(auth: Record<string, unknown>, providerId: string): void {
365
+ const proxied = auth.proxied;
366
+ if (auth.mode !== "oauth2_proxied") {
367
+ if (proxied !== undefined) {
368
+ throw new ValidationError(
369
+ `Provider "${providerId}" auth.proxied is only valid when auth.mode is "oauth2_proxied".`,
370
+ );
371
+ }
372
+ return;
373
+ }
374
+ if (!proxied || typeof proxied !== "object" || Array.isArray(proxied)) {
375
+ throw new ValidationError(
376
+ `Provider "${providerId}" with auth.mode "oauth2_proxied" must declare auth.proxied.`,
377
+ );
378
+ }
379
+ const config = Object.fromEntries(Object.entries(proxied));
380
+ for (const key of Object.keys(config)) {
381
+ if (!PROXIED_OAUTH_ALLOWED_FIELDS.has(key)) {
382
+ throw new ValidationError(
383
+ `Provider "${providerId}" has unknown auth.proxied field "${key}".`,
384
+ );
385
+ }
386
+ }
387
+ for (const field of PROXIED_OAUTH_REQUIRED_FIELDS) {
388
+ if (typeof config[field] !== "string" || !config[field].trim()) {
389
+ throw new ValidationError(
390
+ `Provider "${providerId}" auth.proxied.${field} must be a non-empty string.`,
391
+ );
392
+ }
393
+ }
394
+ for (const field of ["authorizeUrl", "tokenUrl"] as const) {
395
+ try {
396
+ const endpoint = new URL(String(config[field]));
397
+ if (
398
+ endpoint.protocol !== "https:" ||
399
+ endpoint.username ||
400
+ endpoint.password ||
401
+ endpoint.hash
402
+ ) {
403
+ throw new Error("invalid endpoint");
404
+ }
405
+ } catch {
406
+ throw new ValidationError(
407
+ `Provider "${providerId}" auth.proxied.${field} must be an absolute HTTPS URL without credentials or a fragment.`,
408
+ );
409
+ }
410
+ }
411
+ const customScheme = String(config.customScheme);
412
+ if (
413
+ !CUSTOM_SCHEME_REGEX.test(customScheme) ||
414
+ customScheme.toLowerCase().startsWith("http://") ||
415
+ customScheme.toLowerCase().startsWith("https://")
416
+ ) {
417
+ throw new ValidationError(
418
+ `Provider "${providerId}" auth.proxied.customScheme must be a non-HTTP custom-scheme URL prefix.`,
419
+ );
420
+ }
421
+ if (!PROXIED_OAUTH_PROFILE_REGEX.test(String(config.rewriteProfile))) {
422
+ throw new ValidationError(
423
+ `Provider "${providerId}" auth.proxied.rewriteProfile must be a kebab-case profile name.`,
424
+ );
425
+ }
426
+ if (!PROXIED_OAUTH_ENV_KEY_REGEX.test(String(config.clientIdEnvKey))) {
427
+ throw new ValidationError(
428
+ `Provider "${providerId}" auth.proxied.clientIdEnvKey must be an APIFuse-style uppercase environment key.`,
429
+ );
430
+ }
431
+ if (config.pkce !== undefined && config.pkce !== "S256" && config.pkce !== "none") {
432
+ throw new ValidationError(
433
+ `Provider "${providerId}" auth.proxied.pkce must be "S256" or "none".`,
434
+ );
435
+ }
436
+ validateProxiedOAuthParams(config.authorizeParams, "authorizeParams", providerId);
437
+ validateProxiedOAuthParams(config.tokenParams, "tokenParams", providerId);
438
+ }
439
+
306
440
  function validateProviderShape(config: unknown): void {
307
441
  assertObjectConfig(config);
308
442
  assertRequiredField(config, "id");
@@ -315,6 +449,9 @@ function validateProviderShape(config: unknown): void {
315
449
  const auth = config.auth;
316
450
  if (auth && typeof auth === "object" && "mode" in auth && typeof auth.mode === "string")
317
451
  assertLiteralField(auth.mode, "auth.mode", VALID_AUTH_MODES, String(config.id));
452
+ if (auth && typeof auth === "object" && !Array.isArray(auth)) {
453
+ validateProxiedOAuthAuth(Object.fromEntries(Object.entries(auth)), String(config.id));
454
+ }
318
455
  if (auth && typeof auth === "object" && "exchange" in auth) {
319
456
  throw new ProviderError(
320
457
  `Provider "${String(config.id)}" auth.exchange is not part of the Provider SDK auth contract`,
@@ -1,3 +1,5 @@
1
+ import type { ProviderErrorStatus } from "./types.js";
2
+
1
3
  // This set suppresses the unregistered-provider-error-code signal for codes
2
4
  // intentionally emitted by SDK paths. It is not the complete authority for
3
5
  // runtime error resolution: branded errors and additional canonical SDK codes
@@ -89,3 +91,32 @@ export const SDK_RUNTIME_OWNED_ERROR_CODES = new Set([
89
91
  "NOT_FOUND",
90
92
  "not_found",
91
93
  ]);
94
+
95
+ // Canonical SDK status mapping for recognized provider-thrown error codes.
96
+ // serve.ts toStatusCode consults this map (after operation-declared overrides
97
+ // for non-SDK-owned codes), and the authoring lint treats these codes as
98
+ // SDK-registered. Add new codes here instead of duplicating literals in
99
+ // either consumer.
100
+ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, ProviderErrorStatus> =
101
+ new Map<string, ProviderErrorStatus>([
102
+ ["AUTH_REQUIRED", 401],
103
+ ["reauth_required", 401],
104
+ // Unprovisioned declared secret: a deployment/config defect, never an
105
+ // upstream failure — explicit 400.
106
+ ["MISSING_SECRET", 400],
107
+ ["NOT_FOUND", 404],
108
+ ["not_found", 404],
109
+ ["NO_DATA", 404],
110
+ ["RATE_LIMITED", 429],
111
+ ["UPSTREAM_RATE_LIMIT", 429],
112
+ ["LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", 429],
113
+ // Deterministic upstream business refusal (honest-provider-error-
114
+ // contract): the upstream evaluated the request and said no under its
115
+ // own rules — a conflict with upstream state, never a 5xx.
116
+ ["UPSTREAM_REJECTED", 409],
117
+ ["UPSTREAM_ERROR", 502],
118
+ ["BLOCKED", 502],
119
+ ["STT_UNAVAILABLE", 503],
120
+ ["UNSUPPORTED_STT_BACKEND", 503],
121
+ ["STATEFUL_FORWARDING_REPLAY_CACHE_FULL", 503],
122
+ ]);
package/src/index.ts CHANGED
@@ -277,6 +277,7 @@ export type {
277
277
  ProviderProxyConfig,
278
278
  ProviderProxyMode,
279
279
  ProviderProxyPolicy,
280
+ ProxiedOAuthConfig,
280
281
  ProviderProxyProvider,
281
282
  ProviderProxySessionAffinity,
282
283
  ProviderPublicConnectionMode,
package/src/lint.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import type { ZodType } from "zod";
2
2
 
3
+ import {
4
+ SDK_RUNTIME_OWNED_ERROR_CODES,
5
+ SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
6
+ } from "./error-resolution.js";
3
7
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
4
8
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
5
9
 
6
- type AuthModeLike = "none" | "platform-managed" | "credentials" | "oauth2" | "api-key";
10
+ type AuthModeLike =
11
+ | "none"
12
+ | "platform-managed"
13
+ | "credentials"
14
+ | "oauth2"
15
+ | "oauth2_proxied"
16
+ | "api-key";
7
17
 
8
18
  type ProviderAuthLike = {
9
19
  mode?: AuthModeLike;
@@ -793,6 +803,303 @@ function lintSelfHostedBrowserPatterns(
793
803
  return diagnostics;
794
804
  }
795
805
 
806
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
807
+
808
+ const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
809
+
810
+ /**
811
+ * Skips a string literal starting at `startIndex` (which must point at the
812
+ * opening quote). Returns the index of the closing quote, or -1 when the
813
+ * literal is unterminated. Template literals handle nested `${...}`
814
+ * expressions, including strings inside them.
815
+ */
816
+ function skipStringLiteral(source: string, startIndex: number): number {
817
+ const quote = source[startIndex];
818
+ for (let index = startIndex + 1; index < source.length; index++) {
819
+ const char = source[index];
820
+ if (char === "\\") {
821
+ index++;
822
+ continue;
823
+ }
824
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
825
+ index = skipTemplateExpression(source, index + 2);
826
+ if (index < 0) {
827
+ return -1;
828
+ }
829
+ continue;
830
+ }
831
+ if (char === quote) {
832
+ return index;
833
+ }
834
+ if (quote !== "`" && char === "\n") {
835
+ return -1;
836
+ }
837
+ }
838
+ return -1;
839
+ }
840
+
841
+ function skipTemplateExpression(source: string, startIndex: number): number {
842
+ let depth = 1;
843
+ for (let index = startIndex; index < source.length; index++) {
844
+ const char = source[index];
845
+ if (char === '"' || char === "'" || char === "`") {
846
+ index = skipStringLiteral(source, index);
847
+ if (index < 0) {
848
+ return -1;
849
+ }
850
+ continue;
851
+ }
852
+ if (char === "{") {
853
+ depth++;
854
+ } else if (char === "}") {
855
+ depth--;
856
+ if (depth === 0) {
857
+ return index;
858
+ }
859
+ }
860
+ }
861
+ return -1;
862
+ }
863
+
864
+ /**
865
+ * Extracts the argument text of a call whose opening paren has already been
866
+ * consumed (`startIndex` points just past it). Returns undefined when the
867
+ * call never closes in this source, which the caller treats as "skip
868
+ * silently" — this scanner is conservative by design.
869
+ */
870
+ function extractBalancedCallArguments(source: string, startIndex: number): string | undefined {
871
+ let depth = 1;
872
+ for (let index = startIndex; index < source.length; index++) {
873
+ const char = source[index];
874
+ if (char === '"' || char === "'" || char === "`") {
875
+ index = skipStringLiteral(source, index);
876
+ if (index < 0) {
877
+ return undefined;
878
+ }
879
+ continue;
880
+ }
881
+ if (char === "/" && source[index + 1] === "/") {
882
+ const newline = source.indexOf("\n", index);
883
+ if (newline === -1) {
884
+ return undefined;
885
+ }
886
+ index = newline;
887
+ continue;
888
+ }
889
+ if (char === "/" && source[index + 1] === "*") {
890
+ const end = source.indexOf("*/", index + 2);
891
+ if (end === -1) {
892
+ return undefined;
893
+ }
894
+ index = end + 1;
895
+ continue;
896
+ }
897
+ if (char === "(") {
898
+ depth++;
899
+ } else if (char === ")") {
900
+ depth--;
901
+ if (depth === 0) {
902
+ return source.slice(startIndex, index);
903
+ }
904
+ }
905
+ }
906
+ return undefined;
907
+ }
908
+
909
+ /**
910
+ * Collects literal string values of top-level `code:` properties inside a
911
+ * ProviderError/ValidationError options object. Only plain `"..."` / `'...'`
912
+ * literals at options-object depth count; computed codes (identifiers,
913
+ * ternaries, template substitutions, concatenations, escapes) are skipped
914
+ * silently so the rule never guesses.
915
+ */
916
+ function collectLiteralErrorCodeValues(args: string): string[] {
917
+ const codes: string[] = [];
918
+ let braceDepth = 0;
919
+ let parenDepth = 0;
920
+ let bracketDepth = 0;
921
+ let previousSignificantChar = "";
922
+ for (let index = 0; index < args.length; index++) {
923
+ const char = args[index] ?? "";
924
+ if (char === '"' || char === "'" || char === "`") {
925
+ const end = skipStringLiteral(args, index);
926
+ if (end < 0) {
927
+ return codes;
928
+ }
929
+ index = end;
930
+ previousSignificantChar = char;
931
+ continue;
932
+ }
933
+ if (char === "/" && args[index + 1] === "/") {
934
+ const newline = args.indexOf("\n", index);
935
+ if (newline === -1) {
936
+ return codes;
937
+ }
938
+ index = newline;
939
+ continue;
940
+ }
941
+ if (char === "/" && args[index + 1] === "*") {
942
+ const end = args.indexOf("*/", index + 2);
943
+ if (end === -1) {
944
+ return codes;
945
+ }
946
+ index = end + 1;
947
+ continue;
948
+ }
949
+ if (/\s/.test(char)) {
950
+ continue;
951
+ }
952
+ if (char === "{") {
953
+ braceDepth++;
954
+ } else if (char === "}") {
955
+ braceDepth--;
956
+ } else if (char === "(") {
957
+ parenDepth++;
958
+ } else if (char === ")") {
959
+ parenDepth--;
960
+ } else if (char === "[") {
961
+ bracketDepth++;
962
+ } else if (char === "]") {
963
+ bracketDepth--;
964
+ } else if (
965
+ braceDepth === 1 &&
966
+ parenDepth === 0 &&
967
+ bracketDepth === 0 &&
968
+ (previousSignificantChar === "{" || previousSignificantChar === ",") &&
969
+ args.startsWith("code", index)
970
+ ) {
971
+ let cursor = index + "code".length;
972
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
973
+ cursor++;
974
+ }
975
+ if (args[cursor] === ":") {
976
+ cursor++;
977
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
978
+ cursor++;
979
+ }
980
+ const quote = args[cursor];
981
+ if (quote === '"' || quote === "'") {
982
+ const end = skipStringLiteral(args, cursor);
983
+ if (end > cursor) {
984
+ const value = args.slice(cursor + 1, end);
985
+ let after = end + 1;
986
+ while (after < args.length && /\s/.test(args[after] ?? "")) {
987
+ after++;
988
+ }
989
+ const nextChar = after < args.length ? (args[after] ?? "") : "";
990
+ if (!value.includes("\\") && (nextChar === "," || nextChar === "}" || nextChar === "")) {
991
+ codes.push(value);
992
+ }
993
+ index = end;
994
+ previousSignificantChar = quote;
995
+ continue;
996
+ }
997
+ return codes;
998
+ }
999
+ }
1000
+ }
1001
+ previousSignificantChar = char;
1002
+ }
1003
+ return codes;
1004
+ }
1005
+
1006
+ function collectLiteralThrownErrorCodes(source: string): string[] {
1007
+ const codes: string[] = [];
1008
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
1009
+ for (
1010
+ let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source);
1011
+ match;
1012
+ match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)
1013
+ ) {
1014
+ const argsStart = match.index + match[0].length;
1015
+ const args = extractBalancedCallArguments(source, argsStart);
1016
+ if (args !== undefined) {
1017
+ codes.push(...collectLiteralErrorCodeValues(args));
1018
+ }
1019
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
1020
+ }
1021
+ return codes;
1022
+ }
1023
+
1024
+ /**
1025
+ * Static counterpart of the runtime `unregistered_provider_error_code`
1026
+ * signal (honest-provider-error-contract Phase 3.5.5): flags
1027
+ * `new ProviderError(...)` / `new ValidationError(...)` constructions whose
1028
+ * literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
1029
+ * plus the canonical status-mapped codes shared with serve.ts toStatusCode)
1030
+ * nor declared in any operation's docs.errorCodes. At runtime such a code
1031
+ * serves HTTP 500 and emits the signal; this rule surfaces it at check time.
1032
+ *
1033
+ * A throw site cannot be attributed to a specific operation statically —
1034
+ * providers routinely throw from helpers shared across operations — so this
1035
+ * rule matches against the provider-level union of declared codes. That is
1036
+ * the honest scope: it will not catch a code declared only on the "wrong"
1037
+ * operation, and it never claims per-operation attribution it cannot prove.
1038
+ * Only literal string codes are checked; computed/dynamic codes and test
1039
+ * sources are skipped silently. Warning level: the long tail of existing
1040
+ * providers converges gradually, so this must not fail `apifuse check`.
1041
+ */
1042
+ function lintUndeclaredThrownErrorCodes(provider: {
1043
+ authFlowSource?: string;
1044
+ providerSourceFiles?: Record<string, string>;
1045
+ operations?: Record<
1046
+ string,
1047
+ {
1048
+ handler?: unknown;
1049
+ source?: string;
1050
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
1051
+ }
1052
+ >;
1053
+ }): LintDiagnostic[] {
1054
+ const knownCodes = new Set<string>([
1055
+ ...SDK_RUNTIME_OWNED_ERROR_CODES,
1056
+ ...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
1057
+ ]);
1058
+ for (const operation of Object.values(provider.operations ?? {})) {
1059
+ for (const entry of operation.docs?.errorCodes ?? []) {
1060
+ if (typeof entry?.code === "string") {
1061
+ knownCodes.add(entry.code);
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const sources: Array<{ field: string; source: string }> = [];
1067
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1068
+ ([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
1069
+ );
1070
+ if (sourceFiles.length > 0) {
1071
+ for (const [filePath, source] of sourceFiles) {
1072
+ sources.push({ field: `sourceFiles.${filePath}`, source });
1073
+ }
1074
+ } else {
1075
+ if (provider.authFlowSource) {
1076
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1077
+ }
1078
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1079
+ const source = getOperationSource(operation);
1080
+ if (source) {
1081
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ const diagnostics: LintDiagnostic[] = [];
1087
+ for (const { field, source } of sources) {
1088
+ const undeclaredCodes = new Set(
1089
+ collectLiteralThrownErrorCodes(source).filter((code) => !knownCodes.has(code)),
1090
+ );
1091
+ for (const code of undeclaredCodes) {
1092
+ diagnostics.push({
1093
+ rule: "thrown-error-code-undeclared",
1094
+ level: "warn",
1095
+ field,
1096
+ message: `Thrown error code "${code}" (${field}) is neither SDK-registered nor declared in any operation's docs.errorCodes; at runtime it serves HTTP 500 and emits the unregistered_provider_error_code signal. Declare it in the owning operation's docs.errorCodes with status and retryable.`,
1097
+ });
1098
+ }
1099
+ }
1100
+ return diagnostics;
1101
+ }
1102
+
796
1103
  export function lintOperation(op: {
797
1104
  description?: string;
798
1105
  descriptionKey?: string;
@@ -935,6 +1242,7 @@ export function lintProvider(
935
1242
  derivations?: Record<string, string>;
936
1243
  handler?: unknown;
937
1244
  source?: string;
1245
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
938
1246
  }
939
1247
  >;
940
1248
  meta?: {
@@ -952,6 +1260,7 @@ export function lintProvider(
952
1260
  ...lintCredentialWriteUsage(provider),
953
1261
  ...lintPlaywrightDirectImports(provider),
954
1262
  ...lintSelfHostedBrowserPatterns(provider, options),
1263
+ ...lintUndeclaredThrownErrorCodes(provider),
955
1264
  ];
956
1265
 
957
1266
  if (provider.operations) {
package/src/provider.ts CHANGED
@@ -148,6 +148,7 @@ export type {
148
148
  ProviderLocaleKeyInput,
149
149
  ProviderLogoProfile,
150
150
  ProviderProxyPolicy,
151
+ ProxiedOAuthConfig,
151
152
  ProviderPublicConnectionMode,
152
153
  ProviderPublicProfile,
153
154
  ProviderResolvedFile,
@@ -48,6 +48,7 @@ export function createScratchpad(
48
48
  }
49
49
 
50
50
  export function createFlowContext(options: {
51
+ flowId?: string;
51
52
  http: HttpClient;
52
53
  stealth: StealthClient;
53
54
  env: EnvContext;
@@ -60,6 +61,7 @@ export function createFlowContext(options: {
60
61
  stt?: SttContext;
61
62
  }): FlowContext {
62
63
  return {
64
+ flowId: options.flowId,
63
65
  connectionId: options.connectionId,
64
66
  externalRef: options.externalRef,
65
67
  tenantId: options.tenantId,