@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.
- package/AUTHORING.md +34 -2
- package/CHANGELOG.md +8 -0
- package/README.md +18 -7
- package/bin/apifuse-record.ts +81 -21
- package/bin/apifuse-submit-check.ts +170 -28
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/fixture-sanitization.js +159 -15
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +549 -11
- package/dist/runtime/diagnostic-redactor.d.ts +6 -1
- package/dist/runtime/diagnostic-redactor.js +16 -14
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +17 -103
- package/dist/runtime/instrumentation.js +4 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +33 -1
- package/dist/runtime/redirects.js +113 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-usage.d.ts +8 -0
- package/dist/runtime/resolver-usage.js +5 -11
- package/dist/runtime/resolver-vendors/browser.js +5 -4
- package/dist/runtime/resolver-vendors/capsolver.js +11 -10
- package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
- package/dist/runtime/resolver.js +7 -6
- package/dist/runtime/stealth-owned-headers.d.ts +21 -8
- package/dist/runtime/stealth-owned-headers.js +28 -8
- package/dist/runtime/stealth-telemetry.d.ts +1 -1
- package/dist/runtime/stealth.js +158 -46
- package/dist/runtime/trace.js +2 -2
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +45 -30
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/dist/testing/run.js +14 -7
- package/dist/trace-sanitization.js +1 -1
- package/dist/types.d.ts +30 -6
- package/package.json +2 -2
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/fixture-sanitization.ts +175 -17
- package/src/lint.ts +595 -10
- package/src/runtime/diagnostic-redactor.ts +19 -13
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +24 -112
- package/src/runtime/instrumentation.ts +4 -1
- package/src/runtime/provider.ts +15 -0
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +150 -1
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-usage.ts +8 -5
- package/src/runtime/resolver-vendors/browser.ts +5 -4
- package/src/runtime/resolver-vendors/capsolver.ts +11 -10
- package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
- package/src/runtime/resolver.ts +7 -6
- package/src/runtime/stealth-owned-headers.ts +34 -8
- package/src/runtime/stealth-telemetry.ts +6 -1
- package/src/runtime/stealth.ts +180 -58
- package/src/runtime/trace.ts +6 -2
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +58 -31
- package/src/server/state-backend-report.ts +185 -0
- package/src/testing/run.ts +15 -2
- package/src/trace-sanitization.ts +1 -1
- 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
|
+
}
|
package/src/testing/run.ts
CHANGED
|
@@ -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
|
-
|
|
360
|
-
|
|
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
|
|
1447
|
-
* `Sec-Fetch-User: ?1` (default `true`). Set `false` for
|
|
1448
|
-
* navigations such as `location.replace
|
|
1449
|
-
* Ignored for the `xhr` and
|
|
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>;
|