@apifuse/provider-sdk 2.2.0-beta.67 → 2.2.0-beta.69

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 (77) hide show
  1. package/AUTHORING.md +34 -2
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -7
  4. package/bin/apifuse-record.ts +81 -21
  5. package/bin/apifuse-submit-check.ts +170 -28
  6. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  7. package/dist/cli/templates/provider/README.md.tpl +17 -9
  8. package/dist/config/loader.d.ts +16 -0
  9. package/dist/config/loader.js +25 -8
  10. package/dist/error-resolution.d.ts +13 -0
  11. package/dist/error-resolution.js +72 -35
  12. package/dist/fixture-sanitization.js +159 -15
  13. package/dist/lint.d.ts +2 -0
  14. package/dist/lint.js +549 -11
  15. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  16. package/dist/runtime/diagnostic-redactor.js +16 -14
  17. package/dist/runtime/executor.js +3 -0
  18. package/dist/runtime/http.d.ts +2 -0
  19. package/dist/runtime/http.js +17 -103
  20. package/dist/runtime/instrumentation.js +4 -1
  21. package/dist/runtime/provider.d.ts +7 -0
  22. package/dist/runtime/provider.js +14 -0
  23. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  24. package/dist/runtime/redirect-hop-headers.js +58 -0
  25. package/dist/runtime/redirects.d.ts +33 -1
  26. package/dist/runtime/redirects.js +113 -0
  27. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  28. package/dist/runtime/resolver-span-attributes.js +93 -0
  29. package/dist/runtime/resolver-usage.d.ts +8 -0
  30. package/dist/runtime/resolver-usage.js +5 -11
  31. package/dist/runtime/resolver-vendors/browser.js +5 -4
  32. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  33. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  34. package/dist/runtime/resolver.js +7 -6
  35. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  36. package/dist/runtime/stealth-owned-headers.js +28 -8
  37. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  38. package/dist/runtime/stealth.js +158 -46
  39. package/dist/runtime/trace.js +2 -2
  40. package/dist/server/index.d.ts +1 -0
  41. package/dist/server/serve-implementation.d.ts +2 -1
  42. package/dist/server/serve-implementation.js +45 -30
  43. package/dist/server/state-backend-report.d.ts +70 -0
  44. package/dist/server/state-backend-report.js +113 -0
  45. package/dist/testing/run.js +14 -7
  46. package/dist/trace-sanitization.js +1 -1
  47. package/dist/types.d.ts +30 -6
  48. package/package.json +2 -2
  49. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  50. package/src/cli/templates/provider/README.md.tpl +17 -9
  51. package/src/config/loader.ts +49 -12
  52. package/src/error-resolution.ts +89 -35
  53. package/src/fixture-sanitization.ts +175 -17
  54. package/src/lint.ts +595 -10
  55. package/src/runtime/diagnostic-redactor.ts +19 -13
  56. package/src/runtime/executor.ts +3 -0
  57. package/src/runtime/http.ts +24 -112
  58. package/src/runtime/instrumentation.ts +4 -1
  59. package/src/runtime/provider.ts +15 -0
  60. package/src/runtime/redirect-hop-headers.ts +71 -0
  61. package/src/runtime/redirects.ts +150 -1
  62. package/src/runtime/resolver-span-attributes.ts +139 -0
  63. package/src/runtime/resolver-usage.ts +8 -5
  64. package/src/runtime/resolver-vendors/browser.ts +5 -4
  65. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  66. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  67. package/src/runtime/resolver.ts +7 -6
  68. package/src/runtime/stealth-owned-headers.ts +34 -8
  69. package/src/runtime/stealth-telemetry.ts +6 -1
  70. package/src/runtime/stealth.ts +180 -58
  71. package/src/runtime/trace.ts +6 -2
  72. package/src/server/index.ts +4 -0
  73. package/src/server/serve-implementation.ts +58 -31
  74. package/src/server/state-backend-report.ts +185 -0
  75. package/src/testing/run.ts +15 -2
  76. package/src/trace-sanitization.ts +1 -1
  77. package/src/types.ts +30 -6
@@ -0,0 +1,185 @@
1
+ import {
2
+ providerCacheRedisResolutionFromEnv,
3
+ type ProviderRedisUrlResolution,
4
+ providerStateRedisResolutionFromEnv,
5
+ } from "../config/loader.js";
6
+
7
+ /**
8
+ * What a provider store resolved to at boot.
9
+ *
10
+ * `endpoint` is deliberately host and port only: the URL can carry a password
11
+ * (`redis://:secret@host:6379`), and the diagnostic inventory already treats
12
+ * those credential components — and only those — as sensitive
13
+ * (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
14
+ * includePathAndQuery: false })`). A URL that does not parse contributes no
15
+ * endpoint at all rather than a redacted-looking fragment of itself.
16
+ */
17
+ export type ProviderStoreBackendReport = {
18
+ /**
19
+ * `redis` when a URL resolved, `memory` for the in-process fallback,
20
+ * `unsupported` when state has no backend and every call fails closed,
21
+ * `injected` when the host handed the server a store object.
22
+ */
23
+ readonly backend: "redis" | "memory" | "unsupported" | "injected";
24
+ /** Env name that supplied the Redis URL. Present only for `redis`. */
25
+ readonly envName?: string;
26
+ /**
27
+ * `host:port` of the resolved Redis. Never includes credentials, path or
28
+ * query. Absent when the URL does not parse, or when its query carries an
29
+ * ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
30
+ * so the authority is not where the client connects — see
31
+ * `state_redis_url_ambiguous_endpoint`.
32
+ */
33
+ readonly endpoint?: string;
34
+ /** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
35
+ readonly scheme?: string;
36
+ /** True when the URL came from a shared fallback env rather than the store's own. */
37
+ readonly fallback?: boolean;
38
+ };
39
+
40
+ /**
41
+ * One line per process boot naming the stores the provider resolved. Without
42
+ * it, a provider silently riding the cache Redis for its state is
43
+ * indistinguishable from one on a durable instance, from outside the pod and
44
+ * from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
45
+ * verified by inference from the Deployment spec).
46
+ */
47
+ export type ProviderStateBackendLogEvent = {
48
+ /** `warn` when a warning was raised, otherwise `info`. */
49
+ readonly level: "info" | "warn";
50
+ readonly event: "provider_state_backend";
51
+ readonly providerId: string;
52
+ readonly state: ProviderStoreBackendReport;
53
+ readonly cache: ProviderStoreBackendReport;
54
+ readonly sdkVersion: string;
55
+ /**
56
+ * Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
57
+ * sharing an instance provisioned for something else),
58
+ * `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
59
+ * URL's query carries an ioredis option that moves the endpoint, so the
60
+ * authority is not reported as the endpoint). Absent when the resolution
61
+ * was unambiguous.
62
+ */
63
+ readonly warnings?: readonly string[];
64
+ };
65
+
66
+ /**
67
+ * Whether ioredis' own URL parsing would move the endpoint away from the
68
+ * authority this function can see. `createProviderRedisClient` hands the raw
69
+ * URL to `new Redis(url, …)`, and `parseURL` ends with
70
+ * `defaults(result, queryOptions)` — lodash `defaults`, so a query key only
71
+ * applies where the authority supplied nothing:
72
+ *
73
+ * - `path` is never derived from the authority of a `redis://` URL (that
74
+ * pathname is the db index), so `?path=` always applies — and
75
+ * `StandaloneConnector` connects to `options.path` INSTEAD of host/port.
76
+ * The client is then on a Unix socket the authority says nothing about.
77
+ * - `port` applies only when the URL carries no explicit port.
78
+ * - `host` never applies here: we return early unless `parsed.hostname` is
79
+ * set, so `result.host` is always already populated.
80
+ * - `family` selects an address family; it does not move `host:port`.
81
+ *
82
+ * Verified against the pinned ioredis 5.11.1. In those cases the endpoint is
83
+ * WITHHELD and the ambiguity named, rather than reported as the authority — a
84
+ * diagnostic whose whole job is to answer "which Redis is this provider on"
85
+ * must not answer it wrongly. The query is never echoed: it can carry a
86
+ * password, which is why `endpoint` is host-and-port only to begin with.
87
+ */
88
+ function queryMovesEndpoint(parsed: URL): boolean {
89
+ if (parsed.searchParams.has("path")) return true;
90
+ return parsed.searchParams.has("port") && parsed.port === "";
91
+ }
92
+
93
+ function redisEndpoint(url: string): {
94
+ endpoint?: string;
95
+ scheme?: string;
96
+ ambiguous?: boolean;
97
+ } {
98
+ let parsed: URL;
99
+ try {
100
+ parsed = new URL(url);
101
+ } catch {
102
+ return {};
103
+ }
104
+ // No authority means this is not the `redis://host:port` shape at all (e.g.
105
+ // a bare `host:port`, which parses as a scheme). Report nothing rather than
106
+ // a fragment of a string whose parts we have not identified.
107
+ if (!parsed.hostname) return {};
108
+ const scheme = parsed.protocol.replace(/:$/, "");
109
+ if (queryMovesEndpoint(parsed)) return { scheme, ambiguous: true };
110
+ return {
111
+ scheme,
112
+ endpoint: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
113
+ };
114
+ }
115
+
116
+ function redisReport(resolution: ProviderRedisUrlResolution): {
117
+ report: ProviderStoreBackendReport;
118
+ parsed: boolean;
119
+ ambiguous: boolean;
120
+ } {
121
+ const { endpoint, scheme, ambiguous } = redisEndpoint(resolution.url);
122
+ return {
123
+ parsed: endpoint !== undefined || ambiguous === true,
124
+ ambiguous: ambiguous === true,
125
+ report: {
126
+ backend: "redis",
127
+ envName: resolution.envName,
128
+ ...(endpoint === undefined ? {} : { endpoint }),
129
+ ...(scheme === undefined ? {} : { scheme }),
130
+ ...(resolution.fallback ? { fallback: true } : {}),
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Build the boot event from the same env precedence the stores themselves use
137
+ * (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
138
+ * so the line can never describe a backend other than the one in use.
139
+ */
140
+ export function providerStateBackendLogEvent(input: {
141
+ readonly providerId: string;
142
+ readonly sdkVersion: string;
143
+ /** The host passed an explicit state store, so no env resolution happened. */
144
+ readonly injectedState: boolean;
145
+ /** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
146
+ readonly allowMemoryFallback: boolean;
147
+ }): ProviderStateBackendLogEvent {
148
+ const warnings: string[] = [];
149
+ let state: ProviderStoreBackendReport;
150
+ if (input.injectedState) {
151
+ state = { backend: "injected" };
152
+ } else {
153
+ const resolution = providerStateRedisResolutionFromEnv();
154
+ if (resolution) {
155
+ const { report, parsed, ambiguous } = redisReport(resolution);
156
+ state = report;
157
+ if (!parsed) warnings.push("state_redis_url_unparsed");
158
+ if (ambiguous) warnings.push("state_redis_url_ambiguous_endpoint");
159
+ if (resolution.fallback) warnings.push("state_redis_url_fallback");
160
+ } else {
161
+ state = { backend: input.allowMemoryFallback ? "memory" : "unsupported" };
162
+ }
163
+ }
164
+
165
+ const cacheResolution = providerCacheRedisResolutionFromEnv();
166
+ let cache: ProviderStoreBackendReport;
167
+ if (cacheResolution) {
168
+ const { report, parsed, ambiguous } = redisReport(cacheResolution);
169
+ cache = report;
170
+ if (!parsed) warnings.push("cache_redis_url_unparsed");
171
+ if (ambiguous) warnings.push("cache_redis_url_ambiguous_endpoint");
172
+ } else {
173
+ cache = { backend: "memory" };
174
+ }
175
+
176
+ return {
177
+ level: warnings.length > 0 ? "warn" : "info",
178
+ event: "provider_state_backend",
179
+ providerId: input.providerId,
180
+ state,
181
+ cache,
182
+ sdkVersion: input.sdkVersion,
183
+ ...(warnings.length === 0 ? {} : { warnings }),
184
+ };
185
+ }
@@ -10,6 +10,7 @@ import { createTestHandleContext } from "../runtime/handle.js";
10
10
  */
11
11
  const STANDARD_TEST_CONNECTION_ID = "standard-test-connection";
12
12
  import { createMemoryProviderRuntimeState } from "../runtime/state.js";
13
+ import { assertCallerHeadersSupported } from "../runtime/stealth-owned-headers.js";
13
14
  import { createUnsupportedOcrClient } from "../runtime/ocr.js";
14
15
  import { createUnsupportedSttClient } from "../runtime/stt.js";
15
16
  import {
@@ -356,8 +357,19 @@ function createUpstreamContext(
356
357
  options?: unknown,
357
358
  ): Promise<HttpResponse> =>
358
359
  toHttpResponse(await dispatch({ transport: "http", method, url, body, options }));
359
- const stealthCall = async (url: string, options?: { method?: string; body?: unknown }) =>
360
- toStealthResponse(
360
+ // The production transport rejects owned headers (User-Agent, Sec-Fetch-*,
361
+ // sec-ch-ua*, ...) before any byte is sent; the fake applies the same
362
+ // function, so a provider test fails exactly where production would.
363
+ const stealthCall = async (
364
+ url: string,
365
+ options?: {
366
+ method?: string;
367
+ body?: unknown;
368
+ headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
369
+ },
370
+ ) => {
371
+ assertCallerHeadersSupported(options?.headers ?? {});
372
+ return toStealthResponse(
361
373
  await dispatch({
362
374
  transport: "stealth",
363
375
  method: options?.method?.toUpperCase() ?? "GET",
@@ -367,6 +379,7 @@ function createUpstreamContext(
367
379
  }),
368
380
  url,
369
381
  );
382
+ };
370
383
 
371
384
  const createBrowserPage = (): BrowserPage => {
372
385
  let currentUrl = "about:blank";
@@ -52,7 +52,7 @@ export function sanitizeTraceAttributes(
52
52
  const valueRedactor =
53
53
  typeof value === "bigint" || isDiagnosticBigintAttribute(attributes, key)
54
54
  ? diagnosticStructuredRedactor(redact)
55
- : diagnosticAttributeRedactor(key, redact);
55
+ : diagnosticAttributeRedactor(key, redact, value);
56
56
  const sanitizedValue = keyChanged
57
57
  ? redactedKey === REDACTION_FAILED
58
58
  ? REDACTION_FAILED
package/src/types.ts CHANGED
@@ -1414,7 +1414,23 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
1414
1414
  headers?: Record<string, string | string[]>;
1415
1415
  method?: HttpMethod;
1416
1416
  body?: string | Buffer;
1417
+ /**
1418
+ * Defaults to `"follow"`. Every followed hop drops the request-body headers
1419
+ * when the method changes, `Authorization` and `Proxy-Authorization` when it
1420
+ * leaves the current origin, and an explicit `Cookie` when it leaves the
1421
+ * current site (scheme plus registrable domain); after that the session
1422
+ * cookie jar supplies cookies for the new site.
1423
+ */
1417
1424
  redirect?: "follow" | "manual" | "error";
1425
+ /**
1426
+ * Opt-in same-origin redirect enforcement, the ctx.http `redirectPolicy`
1427
+ * for the stealth transport. A redirect whose target leaves the initial
1428
+ * origin, exceeds `maxHops` (at most 10, the stealth redirect limit),
1429
+ * loops, or has no usable `Location` throws `HttpRedirectError` before any
1430
+ * request is sent to that target. Requires the default
1431
+ * `redirect: "follow"`.
1432
+ */
1433
+ redirectPolicy?: HttpRedirectPolicy;
1418
1434
  /**
1419
1435
  * Maximum decoded response-body bytes to buffer. When set, the stealth
1420
1436
  * transport aborts the response and throws `response_too_large` if the
@@ -1440,13 +1456,21 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
1440
1456
  * replacement for hand-set `Sec-Fetch-*` headers, which the transport
1441
1457
  * rejects; it drives `Sec-Fetch-Mode`/`Dest`/`User`, `Accept`, `Priority`,
1442
1458
  * and `Upgrade-Insecure-Requests`.
1459
+ *
1460
+ * `form-post` is an HTML `<form method="post">` submission: a navigation
1461
+ * (`navigate`/`document`, the document `Accept`, `Upgrade-Insecure-Requests`)
1462
+ * that carries a body, in the header order Chrome uses for it, with
1463
+ * `Cache-Control: max-age=0` and the caller's `Content-Type`, `Origin` and
1464
+ * `Referer` in place. It is never inferred, since a form submission and a
1465
+ * script `fetch()` POST look the same to the transport.
1443
1466
  */
1444
- requestClass?: "navigation" | "xhr" | "post";
1467
+ requestClass?: "navigation" | "form-post" | "xhr" | "post";
1445
1468
  /**
1446
- * Whether a `navigation` request carries a user gesture and therefore
1447
- * `Sec-Fetch-User: ?1` (default `true`). Set `false` for script-driven
1448
- * navigations such as `location.replace`, which Chrome sends without it.
1449
- * Ignored for the `xhr` and `post` request classes.
1469
+ * Whether a `navigation` or `form-post` request carries a user gesture and
1470
+ * therefore `Sec-Fetch-User: ?1` (default `true`). Set `false` for
1471
+ * script-driven navigations such as `location.replace` or
1472
+ * `form.submit()`, which Chrome sends without it. Ignored for the `xhr` and
1473
+ * `post` request classes.
1450
1474
  */
1451
1475
  userActivation?: boolean;
1452
1476
  /**
@@ -1551,7 +1575,7 @@ export interface StealthRedirectHop {
1551
1575
  }
1552
1576
 
1553
1577
  export interface StealthRedirectRunOptions
1554
- extends Omit<StealthFetchOptions, "redirect"> {
1578
+ extends Omit<StealthFetchOptions, "redirect" | "redirectPolicy"> {
1555
1579
  url: string;
1556
1580
  maxHops?: number;
1557
1581
  stopWhen?: (hop: StealthRedirectHop) => boolean | Promise<boolean>;