@apifuse/provider-sdk 2.1.0-beta.0 → 2.1.0-beta.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Canonical scaffolding command: `apifuse create`
4
4
  - Monorepo contributors should use `apifuse create <name> --preset monorepo`
5
- - Standalone users should use `bunx @apifuse/provider-sdk create <name>`
5
+ - Standalone bounty contributors should use `bunx @apifuse/provider-sdk@beta create <name> --yes` until this release is promoted to `latest`
6
6
  - Provider server contract is:
7
7
  - dev default `3900`
8
8
  - start/Docker/container `3000`
@@ -47,6 +47,7 @@ description:
47
47
  - `description` — 150+ chars English (error-level rule)
48
48
  - Every Zod field in input AND output has `.describe()` including nested objects + array items (error-level rule)
49
49
  - `fixtures.request` + `fixtures.response` both present (error-level rule)
50
+ - Exactly one of `healthCheck` or `healthCheckUnsupported` per operation. Prefer `healthCheck` for safe read-only upstream probes; use `healthCheckUnsupported` only with a specific reason for destructive, paid, credential-sensitive, flaky, or otherwise unsafe probes.
50
51
 
51
52
  ### Factored operations
52
53
 
@@ -62,31 +63,21 @@ Use `defineOperation()` when an operation is large enough to live beside helper
62
63
 
63
64
  - `annotations`: `{ readOnly, destructive, idempotent, openWorld, rateLimit }` — agentic safety signals
64
65
  - `tags`: operation-level semantic tags for retrieval (e.g., `["weather", "korea", "realtime"]`)
65
- - `relatedOperations`: `{ alternatives?: string[]; chainsWith?: string[] }` — links to related operations for composite/fallback suggestions
66
+ - `relatedOperations`: `{ alternatives?: string[] }` — links to fallback/sibling operations
66
67
 
67
- ### Composite operations
68
+ ### External bounty submission evidence
68
69
 
69
- For multi-step chains (e.g., geocode transform forecast), use `defineCompositeOperation()` from `@apifuse/provider-sdk`:
70
+ External contributors are expected to submit standalone Provider source plus:
70
71
 
71
- ```ts
72
- import { defineCompositeOperation, z } from "@apifuse/provider-sdk"
73
-
74
- export const weatherByAddress = defineCompositeOperation({
75
- id: "kma:weather-by-address",
76
- description: "...",
77
- input: z.object({ address: z.string().describe("...") }),
78
- output: ResultSchema,
79
- tags: ["weather", "korea", "composite"],
80
- chainsWith: ["kakaomap:geocode", "kma:short-forecast"],
81
- steps: async (ctx, input) => {
82
- const geo = await ctx.chain.call("kakaomap:geocode", { address: input.address })
83
- if (geo.items.length === 0) {
84
- return ctx.clarify({ question: "...", missing: [{ name: "address", description: "..." }] })
85
- }
86
- // ... continue chain
87
- },
88
- })
89
- ```
72
+ - SDK version/tag and create command used.
73
+ - Provider id, version, runtime, auth mode, and Operation list.
74
+ - Health coverage table for every Operation.
75
+ - `bun run check` output.
76
+ - `bun run test` output.
77
+ - Fixture evidence and known upstream constraints.
78
+
79
+ Maintainers own monorepo import under `providers/<id>/`, registry generation,
80
+ deployment projection checks, and release workflows.
90
81
 
91
82
  ### Running the lint locally
92
83
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.1.0-beta.1
4
+
5
+ - Fix public `apifuse create` runtime packaging by publishing `@clack/prompts` as a production dependency.
6
+ - Update generated Provider starter templates so the sample operation declares a local-only `healthCheckUnsupported` and passes the current health coverage contract.
7
+ - Add packed-artifact smoke coverage for the public create/check/test flow before npm release publishing.
8
+ - Document the public SDK-only bounty contributor path and maintainer-owned monorepo import boundary.
9
+
3
10
  ## 2.1.0-beta.0
4
11
 
5
12
  - BREAKING: collapse the Chrome desktop stealth catalog to `chrome-146` plus the `chrome-desktop` alias. Removed/blocked `chrome-120`, `chrome-124`, `chrome-129`, `chrome-130`, `chrome-131`, `chrome-133`, `chrome-144`, `chrome-146-psk`, `chrome-131-psk`, `chrome-130-psk`, and `edge-131`; migrate callers to `chrome-146`.
package/README.md CHANGED
@@ -8,12 +8,19 @@ ApiFuse Provider SDK — build provider declarations and runtimes with one publi
8
8
  bun add @apifuse/provider-sdk
9
9
  ```
10
10
 
11
+ For external bounty scaffolding, use the beta tag until this release is promoted
12
+ to `latest`:
13
+
14
+ ```bash
15
+ bunx @apifuse/provider-sdk@beta create my-provider --yes
16
+ ```
17
+
11
18
  ## Create a provider
12
19
 
13
20
  ### Standalone (default)
14
21
 
15
22
  ```bash
16
- bunx @apifuse/provider-sdk create my-provider
23
+ bunx @apifuse/provider-sdk@beta create my-provider --yes
17
24
  ```
18
25
 
19
26
  The canonical `create` flow:
@@ -49,9 +56,18 @@ Removed legacy runtime paths are not supported:
49
56
 
50
57
  ```bash
51
58
  cd my-provider
52
- bun run dev
53
59
  bun run check
54
60
  bun run test
61
+ bun run dev
62
+ ```
63
+
64
+ Smoke the generated local server:
65
+
66
+ ```bash
67
+ curl -s http://localhost:3900/health
68
+ curl -s -X POST http://localhost:3900/v1/ping \
69
+ -H 'Content-Type: application/json' \
70
+ -d '{"input":{"value":"hello"},"headers":{},"connection":null}'
55
71
  ```
56
72
 
57
73
  ## Authoring ergonomics
@@ -80,6 +96,18 @@ export default defineProvider({
80
96
 
81
97
  Operation schemas may be Zod schemas or Standard Schema v1-compatible schemas. Invalid configs throw `ProviderError`/`ValidationError` messages that name the offending field, such as `auth.mode` or `operations.search.fixtures.request`.
82
98
 
99
+ ### Operation health coverage
100
+
101
+ Every operation must declare exactly one of:
102
+
103
+ - `healthCheck` — preferred for safe read-only upstream probes.
104
+ - `healthCheckUnsupported` — allowed only when a probe is destructive, paid,
105
+ credential-sensitive, flaky by design, or otherwise unsafe. The `reason` must
106
+ be specific.
107
+
108
+ The generated `ping` operation uses `healthCheckUnsupported` only because it is
109
+ a local scaffold check, not a real upstream API probe.
110
+
83
111
  ### Operation annotations
84
112
 
85
113
  Operations declare non-functional metadata via `annotations`:
@@ -114,3 +142,8 @@ Generator v1 scaffolds **TypeScript providers only** for this redesign. Python g
114
142
  ## Boundary
115
143
 
116
144
  Provider cataloging, deployment enrollment, docs indexing, and runtime discovery are internal platform-registry responsibilities and are not part of the public `@apifuse/provider-sdk` contract.
145
+
146
+ External bounty contributors should submit standalone Provider source plus
147
+ `bun run check` / `bun run test` evidence. ApiFuse maintainers own monorepo
148
+ import, registry generation, deployment projection checks, and release
149
+ publishing.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
4
5
  import { z } from "zod";
5
6
 
6
7
  const PACK_RESULT_SCHEMA = z.array(
@@ -28,12 +29,23 @@ if (!first) {
28
29
  }
29
30
 
30
31
  const filePaths = (first.files ?? []).map((file) => file.path);
32
+ const requiredPaths = [
33
+ "bin/apifuse.ts",
34
+ "bin/apifuse-create.ts",
35
+ "bin/apifuse-pack-smoke.ts",
36
+ "src/cli/create.ts",
37
+ "src/cli/templates/provider/index.ts.tpl",
38
+ "src/cli/templates/provider/README.md.tpl",
39
+ ];
31
40
  const forbiddenMatches = filePaths.filter(
32
41
  (path) =>
33
42
  path.startsWith("src/__tests__/") ||
34
43
  path === "src/index.test.ts" ||
35
44
  path === "bin/apifuse-init.ts",
36
45
  );
46
+ const missingRequiredPaths = requiredPaths.filter(
47
+ (path) => !filePaths.includes(path),
48
+ );
37
49
 
38
50
  if (forbiddenMatches.length > 0) {
39
51
  throw new Error(
@@ -41,6 +53,34 @@ if (forbiddenMatches.length > 0) {
41
53
  );
42
54
  }
43
55
 
56
+ if (missingRequiredPaths.length > 0) {
57
+ throw new Error(
58
+ `Packed artifact is missing required public SDK files:\n${missingRequiredPaths.join("\n")}`,
59
+ );
60
+ }
61
+
62
+ const packageJsonInput: unknown = JSON.parse(
63
+ readFileSync("package.json", "utf8"),
64
+ );
65
+ const packageJson = z
66
+ .object({
67
+ dependencies: z.record(z.string(), z.string()).optional(),
68
+ devDependencies: z.record(z.string(), z.string()).optional(),
69
+ })
70
+ .parse(packageJsonInput);
71
+
72
+ if (!packageJson.dependencies?.["@clack/prompts"]) {
73
+ throw new Error(
74
+ "@clack/prompts is imported by the public create CLI and must be listed in dependencies.",
75
+ );
76
+ }
77
+
78
+ if (packageJson.devDependencies?.["@clack/prompts"]) {
79
+ throw new Error(
80
+ "@clack/prompts must not be devDependency-only because the published create CLI imports it at runtime.",
81
+ );
82
+ }
83
+
44
84
  console.log(`Packed artifact OK: ${first.filename}`);
45
85
  for (const filePath of filePaths) {
46
86
  console.log(` - ${filePath}`);
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { z } from "zod";
14
+
15
+ const PACK_RESULT_SCHEMA = z.array(
16
+ z.object({
17
+ filename: z.string(),
18
+ }),
19
+ );
20
+
21
+ const KEEP_TEMP = process.env.APIFUSE_PACK_SMOKE_KEEP_TEMP === "1";
22
+
23
+ const tempRoot = mkdtempSync(
24
+ join(tmpdir(), "apifuse-provider-sdk-pack-smoke-"),
25
+ );
26
+ const packDir = join(tempRoot, "pack");
27
+ const consumerDir = join(tempRoot, "consumer");
28
+
29
+ try {
30
+ mkdirSync(packDir, { recursive: true });
31
+ mkdirSync(consumerDir, { recursive: true });
32
+
33
+ const packed = packSdk(packDir);
34
+ const tarballPath = resolve(packDir, packed.filename);
35
+ const tarballSpecifier = `file:${tarballPath}`;
36
+
37
+ writeFileSync(
38
+ join(consumerDir, "package.json"),
39
+ `${JSON.stringify(
40
+ {
41
+ private: true,
42
+ type: "module",
43
+ dependencies: {
44
+ "@apifuse/provider-sdk": tarballSpecifier,
45
+ },
46
+ },
47
+ null,
48
+ 2,
49
+ )}\n`,
50
+ );
51
+
52
+ run("bun", ["install"], consumerDir);
53
+
54
+ const cliBin = join(consumerDir, "node_modules", ".bin", "apifuse");
55
+ if (!existsSync(cliBin)) {
56
+ throw new Error(`Expected CLI bin at ${cliBin}`);
57
+ }
58
+
59
+ run(
60
+ "bun",
61
+ [
62
+ cliBin,
63
+ "create",
64
+ "dx-smoke",
65
+ "--yes",
66
+ "--json",
67
+ "--sdk-specifier",
68
+ tarballSpecifier,
69
+ ],
70
+ consumerDir,
71
+ );
72
+
73
+ const generatedProviderDir = join(consumerDir, "dx-smoke");
74
+ run("bun", ["run", "check"], generatedProviderDir);
75
+ run("bun", ["run", "test"], generatedProviderDir);
76
+
77
+ console.log(
78
+ `Provider SDK packed-artifact smoke passed: ${tarballPath} -> ${generatedProviderDir}`,
79
+ );
80
+ } finally {
81
+ if (KEEP_TEMP) {
82
+ console.log(`Keeping smoke temp directory: ${tempRoot}`);
83
+ } else {
84
+ rmSync(tempRoot, { recursive: true, force: true });
85
+ }
86
+ }
87
+
88
+ function packSdk(destination: string): { filename: string } {
89
+ const raw = execFileSync(
90
+ "npm",
91
+ ["pack", "--json", "--pack-destination", destination],
92
+ {
93
+ cwd: process.cwd(),
94
+ encoding: "utf8",
95
+ stdio: ["ignore", "pipe", "inherit"],
96
+ },
97
+ );
98
+ const parsed = PACK_RESULT_SCHEMA.parse(JSON.parse(raw));
99
+ const first = parsed[0];
100
+ if (!first) {
101
+ throw new Error("npm pack --json returned no package metadata.");
102
+ }
103
+ return first;
104
+ }
105
+
106
+ function run(command: string, args: string[], cwd: string): void {
107
+ const result = spawnSync(command, args, {
108
+ cwd,
109
+ env: process.env,
110
+ stdio: "inherit",
111
+ });
112
+
113
+ if (result.error) {
114
+ throw result.error;
115
+ }
116
+
117
+ if (result.status !== 0) {
118
+ throw new Error(
119
+ `Command failed (${[command, ...args].join(" ")}) in ${cwd} with exit code ${result.status}`,
120
+ );
121
+ }
122
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@apifuse/provider-sdk",
3
- "version": "2.1.0-beta.0",
3
+ "version": "2.1.0-beta.1",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "ApiFuse Provider SDK \u2014 Build providers with zero architectural constraints",
6
+ "description": "ApiFuse Provider SDK Build providers with zero architectural constraints",
7
7
  "license": "MIT",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
@@ -61,16 +61,17 @@
61
61
  "type-check": "tsgo --noEmit",
62
62
  "test": "bun test",
63
63
  "check": "bun run lint && bun run type-check",
64
- "pack:check": "bun bin/apifuse-pack-check.ts"
64
+ "pack:check": "bun bin/apifuse-pack-check.ts",
65
+ "pack:smoke": "bun bin/apifuse-pack-smoke.ts"
65
66
  },
66
67
  "devDependencies": {
67
68
  "@biomejs/biome": "^2.4.12",
68
- "@clack/prompts": "^1.2.0",
69
69
  "@types/bun": "latest",
70
70
  "@types/node": "^25.1.0",
71
71
  "typescript": "^6.0.3"
72
72
  },
73
73
  "dependencies": {
74
+ "@clack/prompts": "^1.2.0",
74
75
  "ajv": "^8.17",
75
76
  "hono": "^4.12.14",
76
77
  "playwright": "^1.55.1",
@@ -63,6 +63,7 @@ const DEVICE_FLOW_KEY = "__device_flow";
63
63
  const MAGIC_LINK_KEY = "__magic_link";
64
64
  const COMBINED_STAGE_KEY = "__combined_stage";
65
65
  const SWITCH_SELECTION_KEY = "__switch_selection";
66
+ const FORM_FIELD_ORDER_EXTENSION = "x-apifuse-field-order";
66
67
 
67
68
  function isRecord(value: unknown): value is JsonObject {
68
69
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -175,11 +176,31 @@ function buildJsonSchemaForm(
175
176
  ): AuthTurn {
176
177
  return createTurn("form", {
177
178
  hint,
178
- expectedInput,
179
+ expectedInput: withDeclaredFormFieldOrder(expectedInput),
179
180
  data: {},
180
181
  });
181
182
  }
182
183
 
184
+ function withDeclaredFormFieldOrder(expectedInput: JsonObject): JsonObject {
185
+ const properties = expectedInput.properties;
186
+ if (!isRecord(properties)) {
187
+ return expectedInput;
188
+ }
189
+
190
+ const existingOrder = expectedInput[FORM_FIELD_ORDER_EXTENSION];
191
+ if (
192
+ Array.isArray(existingOrder) &&
193
+ existingOrder.every((value) => typeof value === "string")
194
+ ) {
195
+ return expectedInput;
196
+ }
197
+
198
+ return {
199
+ ...expectedInput,
200
+ [FORM_FIELD_ORDER_EXTENSION]: Object.keys(properties),
201
+ };
202
+ }
203
+
183
204
  export function validateCeremonyOutput(turn: unknown): AuthTurn {
184
205
  if (!validateAuthTurn(turn)) {
185
206
  const detail = validateAuthTurn.errors
@@ -25,4 +25,17 @@ bun run test
25
25
 
26
26
  1. Replace the sample `ping` operation with real upstream logic.
27
27
  2. Record a real fixture with `bun run record:sample` or a provider-specific equivalent.
28
- 3. Extend tests and operation metadata until the provider is bounty-ready.
28
+ 3. Replace the starter `healthCheckUnsupported` with a real `healthCheck` for read-only upstream operations when safe.
29
+ 4. Extend tests and operation metadata until the provider is bounty-ready.
30
+
31
+ ## Health-check authorship
32
+
33
+ Every operation must declare exactly one of:
34
+
35
+ - `healthCheck` — preferred for safe read-only upstream probes.
36
+ - `healthCheckUnsupported` — allowed only when a probe is destructive, paid,
37
+ credential-sensitive, flaky by design, or otherwise unsafe. Use a specific
38
+ reason; reviewers reject placeholder reasons such as "TODO" or "later".
39
+
40
+ The generated `ping` operation uses `healthCheckUnsupported` only because it is
41
+ a local scaffold check, not a real upstream API probe.
@@ -49,6 +49,10 @@ export default defineProvider({
49
49
  request: { value: "hello" },
50
50
  response: { ok: true, message: "{{DISPLAY_NAME}} received: hello" },
51
51
  },
52
+ healthCheckUnsupported: {
53
+ reason:
54
+ "Generated local-only scaffold operation. Replace this with a real healthCheck for upstream-backed bounty operations when safe; keep healthCheckUnsupported only for destructive, paid, credential-sensitive, or otherwise unprobeable operations with a specific rationale.",
55
+ },
52
56
  },
53
57
  },
54
58
  });
@@ -43,7 +43,67 @@ export type ResolvedProxyConfig = {
43
43
 
44
44
  function normalizeProxyUrl(url?: string): string | undefined {
45
45
  const normalized = url?.trim();
46
- return normalized ? normalized : undefined;
46
+ return normalized ? applyStickyProxySession(normalized) : undefined;
47
+ }
48
+
49
+ function applyStickyProxySession(proxyUrl: string): string {
50
+ let parsed: URL;
51
+ try {
52
+ parsed = new URL(proxyUrl);
53
+ } catch {
54
+ return proxyUrl;
55
+ }
56
+
57
+ if (!parsed.hostname || !parsed.username || !parsed.password) {
58
+ return proxyUrl;
59
+ }
60
+
61
+ const host = parsed.hostname.toLowerCase();
62
+ if (!host.includes("smartproxy") && !host.includes("decodo")) {
63
+ return proxyUrl;
64
+ }
65
+
66
+ const username = decodeURIComponent(parsed.username);
67
+ const sessionId =
68
+ process.env.APIFUSE_PROXY_SESSION_ID?.trim() || "apifuse-shared";
69
+ const sessionDuration =
70
+ process.env.APIFUSE_PROXY_SESSION_DURATION?.trim() || undefined;
71
+ const stickyUsername = host.includes("smartproxy")
72
+ ? buildSmartproxyUsername(username, sessionId, sessionDuration)
73
+ : buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
74
+
75
+ parsed.username = stickyUsername;
76
+ return parsed.toString();
77
+ }
78
+
79
+ function buildSmartproxyUsername(
80
+ username: string,
81
+ sessionId: string,
82
+ sessionDuration?: string,
83
+ ): string {
84
+ const parts = username.split("_");
85
+ const configuredLife = parts
86
+ .find((part) => part.startsWith("life-"))
87
+ ?.slice("life-".length);
88
+ const baseUsername = parts
89
+ .filter((part) => !part.startsWith("session-") && !part.startsWith("life-"))
90
+ .join("_");
91
+ return `${baseUsername}_session-${sessionId}_life-${sessionDuration ?? configuredLife ?? "60"}`;
92
+ }
93
+
94
+ function buildDecodoUsername(
95
+ username: string,
96
+ sessionId: string,
97
+ sessionDuration: string,
98
+ ): string {
99
+ const withoutSticky = username.replace(
100
+ /-session-.+-sessionduration-\d+$/,
101
+ "",
102
+ );
103
+ const baseUsername = withoutSticky.startsWith("user-")
104
+ ? withoutSticky
105
+ : `user-${withoutSticky}`;
106
+ return `${baseUsername}-session-${sessionId}-sessionduration-${sessionDuration}`;
47
107
  }
48
108
 
49
109
  function syncProxyEnv(config: ApiFuseConfig): void {
package/src/define.ts CHANGED
@@ -233,8 +233,10 @@ const HEALTH_CHECK_CASE_FIELDS = new Set([
233
233
  const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
234
234
  const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
235
235
  "requiredSecrets",
236
+ "probeOverrides",
236
237
  "serviceAccount",
237
238
  ]);
239
+ const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set(["interval"]);
238
240
 
239
241
  function levenshtein(a: string, b: string): number {
240
242
  const m = a.length;
@@ -307,13 +309,13 @@ function validateProviderHealthMonitor(
307
309
  fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
308
310
  },
309
311
  );
312
+ const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
310
313
  rejectUnknownFields(
311
- healthMonitor as Record<string, unknown>,
314
+ healthMonitorRecord,
312
315
  PROVIDER_HEALTH_MONITOR_FIELDS,
313
316
  "healthMonitor",
314
317
  );
315
- const requiredSecrets = (healthMonitor as ProviderHealthMonitorConfig)
316
- .requiredSecrets;
318
+ const requiredSecrets = healthMonitorRecord.requiredSecrets;
317
319
  if (requiredSecrets !== undefined) {
318
320
  if (!Array.isArray(requiredSecrets))
319
321
  throw new ValidationError(
@@ -326,8 +328,44 @@ function validateProviderHealthMonitor(
326
328
  );
327
329
  }
328
330
  }
329
- const serviceAccount = (healthMonitor as ProviderHealthMonitorConfig)
330
- .serviceAccount;
331
+ const probeOverrides = healthMonitorRecord.probeOverrides;
332
+ if (probeOverrides !== undefined) {
333
+ if (
334
+ !probeOverrides ||
335
+ typeof probeOverrides !== "object" ||
336
+ Array.isArray(probeOverrides)
337
+ )
338
+ throw new ValidationError(
339
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
340
+ );
341
+ for (const [probeId, override] of Object.entries(probeOverrides)) {
342
+ if (probeId.length === 0)
343
+ throw new ValidationError(
344
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
345
+ );
346
+ if (!override || typeof override !== "object" || Array.isArray(override))
347
+ throw new ValidationError(
348
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
349
+ );
350
+ const overrideRecord = Object.fromEntries(Object.entries(override));
351
+ rejectUnknownFields(
352
+ overrideRecord,
353
+ PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
354
+ `healthMonitor.probeOverrides["${probeId}"]`,
355
+ );
356
+ const interval = overrideRecord.interval;
357
+ const validProbeIntervals: readonly string[] = PROBE_INTERVALS;
358
+ if (
359
+ interval !== undefined &&
360
+ (typeof interval !== "string" ||
361
+ !validProbeIntervals.includes(interval))
362
+ )
363
+ throw new ValidationError(
364
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be one of ${PROBE_INTERVALS.join(", ")}.`,
365
+ );
366
+ }
367
+ }
368
+ const serviceAccount = healthMonitorRecord.serviceAccount;
331
369
  if (
332
370
  serviceAccount !== undefined &&
333
371
  (typeof serviceAccount !== "string" || serviceAccount.length === 0)
package/src/index.ts CHANGED
@@ -2,12 +2,6 @@
2
2
 
3
3
  export { z } from "zod";
4
4
  export * from "./ceremonies";
5
- export {
6
- type ClarifyResponse,
7
- type CompositeContext,
8
- type CompositeOperationDefinition,
9
- defineCompositeOperation,
10
- } from "./composite";
11
5
  export type {
12
6
  ApiFuseConfig,
13
7
  BrowserConfig,
package/src/provider.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export { z } from "zod";
2
2
 
3
3
  export { createFormCeremony } from "./ceremonies";
4
- export { defineCompositeOperation } from "./composite";
5
4
  export { defineOperation, defineProvider } from "./define";
6
5
  export { AuthError, ProviderError, ValidationError } from "./errors";
7
6
  export type {
@@ -83,13 +83,12 @@ async function doRequest(
83
83
  });
84
84
 
85
85
  if (!response.ok) {
86
- const text = await response.text().catch(() => "");
86
+ await drainFetchResponse(response);
87
87
  throw new TransportError(
88
- `HTTP ${response.status} ${response.statusText}: ${requestUrl}`,
88
+ `Upstream request failed with status ${response.status}`,
89
89
  {
90
90
  code: "upstream_http_error",
91
91
  status: response.status,
92
- fix: `Check the endpoint URL and request parameters. Response: ${text.slice(0, 200)}`,
93
92
  },
94
93
  );
95
94
  }
@@ -121,16 +120,12 @@ async function doRequest(
121
120
  }
122
121
 
123
122
  if (error instanceof Error && error.name === "AbortError") {
124
- throw new TransportError(
125
- `Request timed out: ${resolveUrl(baseUrl, url)}`,
126
- {
127
- code: "transport_timeout",
128
- fix: `Increase timeout option (current: ${timeout}ms)`,
129
- },
130
- );
123
+ throw new TransportError("Request timed out", {
124
+ code: "transport_timeout",
125
+ });
131
126
  }
132
127
 
133
- throw new TransportError(`Network error: ${String(error)}`, {
128
+ throw new TransportError("Network error", {
134
129
  code: "transport_network_error",
135
130
  cause: error instanceof Error ? error : undefined,
136
131
  });
@@ -141,6 +136,27 @@ async function doRequest(
141
136
  }
142
137
  }
143
138
 
139
+ async function drainFetchResponse(response: Response): Promise<void> {
140
+ const reader = response.body?.getReader();
141
+ if (!reader) {
142
+ return;
143
+ }
144
+
145
+ try {
146
+ while (true) {
147
+ const { done } = await reader.read();
148
+ if (done) {
149
+ return;
150
+ }
151
+ }
152
+ } catch {
153
+ // Best-effort drain for transport reuse only; callers still receive the
154
+ // sanitized upstream error below.
155
+ } finally {
156
+ reader.releaseLock();
157
+ }
158
+ }
159
+
144
160
  function createProxyInit(
145
161
  proxy?: string,
146
162
  ): Pick<FetchProxyInit, "dispatcher" | "proxy"> {
@@ -19,6 +19,12 @@ const MISSING_PROXY_WARNING =
19
19
 
20
20
  export type TlsClientOptions = ProxyResolutionOptions & {
21
21
  warn?: (message: string) => void;
22
+ /**
23
+ * Proxy-only TLS transport overrides. Use only for upstream proxy products
24
+ * that terminate CONNECT with a private CA instead of tunneling the origin
25
+ * certificate chain.
26
+ */
27
+ proxyTls?: { insecureSkipVerify?: boolean };
22
28
  };
23
29
 
24
30
  const REMOVED_CHROME_PROFILE_NAMES = new Set([
@@ -187,14 +193,6 @@ export function normalizeResponse(
187
193
  };
188
194
  }
189
195
 
190
- function getErrorMessage(error: unknown): string {
191
- if (error instanceof Error) {
192
- return error.toString();
193
- }
194
-
195
- return String(error);
196
- }
197
-
198
196
  function normalizeBody(body: TlsFetchOptions["body"]): string | null {
199
197
  if (body === undefined) {
200
198
  return null;
@@ -274,6 +272,7 @@ function createSessionFetcher(
274
272
  let sessionClient: SessionClient | null = null;
275
273
  let activeProxy: string | undefined;
276
274
  let activeTlsIdentifier: string | undefined;
275
+ let activeInsecureSkipVerify = false;
277
276
  let hasWarnedMissingProxy = false;
278
277
  const warn = clientOptions.warn ?? console.warn;
279
278
 
@@ -305,19 +304,22 @@ function createSessionFetcher(
305
304
  sessionClient = null;
306
305
  activeProxy = undefined;
307
306
  activeTlsIdentifier = undefined;
307
+ activeInsecureSkipVerify = false;
308
308
  }
309
309
 
310
310
  function getSessionClient(
311
311
  profile?: string,
312
312
  proxy?: string,
313
313
  ja3?: string,
314
+ insecureSkipVerify = false,
314
315
  ): SessionClient {
315
316
  const tlsIdentifier = ja3 ?? resolveIdentifier(profile ?? defaultProfile);
316
317
 
317
318
  if (
318
319
  !sessionClient ||
319
320
  activeProxy !== proxy ||
320
- activeTlsIdentifier !== tlsIdentifier
321
+ activeTlsIdentifier !== tlsIdentifier ||
322
+ activeInsecureSkipVerify !== insecureSkipVerify
321
323
  ) {
322
324
  if (sessionClient) {
323
325
  closeCurrentSession();
@@ -326,10 +328,12 @@ function createSessionFetcher(
326
328
  sessionClient = new SessionClient(moduleClient, {
327
329
  tlsClientIdentifier: tlsIdentifier,
328
330
  ...(proxy ? { proxyUrl: proxy } : {}),
331
+ ...(insecureSkipVerify ? { insecureSkipVerify: true } : {}),
329
332
  timeoutSeconds: 30,
330
333
  } as ConstructorParameters<typeof SessionClient>[1]);
331
334
  activeProxy = proxy;
332
335
  activeTlsIdentifier = tlsIdentifier;
336
+ activeInsecureSkipVerify = insecureSkipVerify;
333
337
  }
334
338
 
335
339
  return sessionClient;
@@ -338,10 +342,15 @@ function createSessionFetcher(
338
342
  return {
339
343
  async fetch(url, options = {}) {
340
344
  const proxy = resolveRequestProxy(options);
345
+ const insecureSkipVerify = Boolean(
346
+ options.tls?.insecureSkipVerify ??
347
+ (proxy && clientOptions.proxyTls?.insecureSkipVerify),
348
+ );
341
349
  const session = getSessionClient(
342
350
  options.profile,
343
351
  proxy,
344
352
  options.tls?.ja3,
353
+ insecureSkipVerify,
345
354
  );
346
355
  const requestUrl = resolveUrl(baseUrl, url);
347
356
 
@@ -352,9 +361,9 @@ function createSessionFetcher(
352
361
  proxy,
353
362
  });
354
363
 
355
- if (response.status >= 400) {
364
+ if (response.status >= 400 && options.throwOnHttpError !== false) {
356
365
  throw new TransportError(
357
- `HTTP ${response.status}: ${response.body || "Request failed"}`,
366
+ `Upstream request failed with status ${response.status}`,
358
367
  {
359
368
  status: response.status,
360
369
  },
@@ -367,7 +376,7 @@ function createSessionFetcher(
367
376
  throw error;
368
377
  }
369
378
 
370
- throw new TransportError(`Network error: ${getErrorMessage(error)}`, {
379
+ throw new TransportError("Network error", {
371
380
  status: 0,
372
381
  cause: error instanceof Error ? error : undefined,
373
382
  });
@@ -215,9 +215,8 @@ function toErrorResponse(
215
215
  return {
216
216
  error: {
217
217
  code: error.code ?? "provider_error",
218
- message: error.message,
218
+ message: publicProviderErrorMessage(error),
219
219
  ...(requestId ? { requestId } : {}),
220
- ...(error.fix ? { fix: error.fix } : {}),
221
220
  ...(error instanceof TransportError && error.status
222
221
  ? { details: { upstreamStatus: error.status } }
223
222
  : {}),
@@ -245,6 +244,21 @@ function toErrorResponse(
245
244
  };
246
245
  }
247
246
 
247
+ function publicProviderErrorMessage(error: ProviderError): string {
248
+ if (error instanceof TransportError) {
249
+ if (error.code === "transport_timeout") return "Request timed out";
250
+ if (error.code === "transport_network_error") return "Network error";
251
+ if (error.code === "upstream_http_error" && error.status) {
252
+ return `Upstream request failed with status ${error.status}`;
253
+ }
254
+ if (error.status) {
255
+ return `Upstream request failed with status ${error.status}`;
256
+ }
257
+ return "Upstream request failed";
258
+ }
259
+ return error.message;
260
+ }
261
+
248
262
  function toStatusCode(error: unknown): 400 | 404 | 500 | 502 | 504 {
249
263
  if (error instanceof z.ZodError) {
250
264
  return 400;
@@ -255,7 +269,7 @@ function toStatusCode(error: unknown): 400 | 404 | 500 | 502 | 504 {
255
269
  }
256
270
 
257
271
  if (error instanceof ProviderError) {
258
- if (error.code === "NOT_FOUND") {
272
+ if (error.code === "NOT_FOUND" || error.code === "NO_DATA") {
259
273
  return 404;
260
274
  }
261
275
 
package/src/types.ts CHANGED
@@ -82,7 +82,6 @@ export const OPERATION_TIMEOUT_MS_MAX = 60_000;
82
82
 
83
83
  export interface OperationRelationships {
84
84
  alternatives?: string[];
85
- chainsWith?: string[];
86
85
  }
87
86
 
88
87
  /**
@@ -97,7 +96,15 @@ export interface OperationRelationships {
97
96
  */
98
97
 
99
98
  /** Polling intervals supported by the health-monitor runtime. */
100
- export type ProbeInterval = "30s" | "1m" | "3m" | "5m" | "15m" | "30m" | "1h";
99
+ export type ProbeInterval =
100
+ | "30s"
101
+ | "1m"
102
+ | "3m"
103
+ | "5m"
104
+ | "15m"
105
+ | "30m"
106
+ | "1h"
107
+ | "24h";
101
108
 
102
109
  export const PROBE_INTERVALS: readonly ProbeInterval[] = [
103
110
  "30s",
@@ -107,6 +114,7 @@ export const PROBE_INTERVALS: readonly ProbeInterval[] = [
107
114
  "15m",
108
115
  "30m",
109
116
  "1h",
117
+ "24h",
110
118
  ] as const;
111
119
 
112
120
  /**
@@ -222,6 +230,13 @@ export interface ProviderHealthMonitorConfig {
222
230
  * `requiresConnection: true`.
223
231
  */
224
232
  requiredSecrets?: string[];
233
+ /**
234
+ * Runtime probe overrides keyed by probe id (for example
235
+ * "catchtable/auth-flow" or "catchtable/waiting-lifecycle"). Use this for
236
+ * health-monitor probes that are provider-scoped or cross-operation and
237
+ * therefore cannot declare an `OperationDefinition.healthCheck.interval`.
238
+ */
239
+ probeOverrides?: Record<string, HealthMonitorProbeOverride>;
225
240
  /**
226
241
  * Override the default service account ID for this provider's probes.
227
242
  * Defaults to the runtime's `APIFUSE_SERVICE_ACCOUNT_ID` env var.
@@ -229,6 +244,11 @@ export interface ProviderHealthMonitorConfig {
229
244
  serviceAccount?: string;
230
245
  }
231
246
 
247
+ export interface HealthMonitorProbeOverride {
248
+ /** Optional runtime interval override. Must be one of PROBE_INTERVALS. */
249
+ interval?: ProbeInterval;
250
+ }
251
+
232
252
  export interface OperationErrorCode {
233
253
  code: string;
234
254
  status?: number;
@@ -300,9 +320,15 @@ export interface TlsFetchOptions extends RequestOptions {
300
320
  method?: string;
301
321
  body?: string | Buffer;
302
322
  profile?: string;
323
+ /**
324
+ * Defaults to true. Set to false when callers need to inspect upstream
325
+ * non-2xx bodies themselves instead of converting them to TransportError.
326
+ */
327
+ throwOnHttpError?: boolean;
303
328
  tls?: {
304
329
  ja3?: string;
305
330
  h2?: Record<string, unknown>;
331
+ insecureSkipVerify?: boolean;
306
332
  };
307
333
  headerOrder?: string[];
308
334
  }
package/src/composite.ts DELETED
@@ -1,43 +0,0 @@
1
- import type { infer as ZodInfer, ZodType } from "zod";
2
-
3
- export interface ClarifyResponse {
4
- _type: "clarify";
5
- question: string;
6
- missing: Array<{ name: string; description: string }>;
7
- }
8
-
9
- export interface CompositeContext {
10
- chain: {
11
- call: <T>(operationKey: string, params: unknown) => Promise<T>;
12
- };
13
- clarify: (opts: {
14
- question: string;
15
- missing: Array<{ name: string; description: string }>;
16
- }) => ClarifyResponse;
17
- setSlot: (name: string, value: unknown) => void;
18
- }
19
-
20
- export interface CompositeOperationDefinition<
21
- TInput extends ZodType = ZodType,
22
- TOutput extends ZodType = ZodType,
23
- > {
24
- id: string;
25
- description: string;
26
- input: TInput;
27
- output: TOutput;
28
- tags?: string[];
29
- chainsWith?: string[];
30
- steps: (
31
- ctx: CompositeContext,
32
- input: ZodInfer<TInput>,
33
- ) => Promise<ZodInfer<TOutput> | ClarifyResponse>;
34
- }
35
-
36
- export function defineCompositeOperation<
37
- TInput extends ZodType,
38
- TOutput extends ZodType,
39
- >(
40
- config: CompositeOperationDefinition<TInput, TOutput>,
41
- ): CompositeOperationDefinition<TInput, TOutput> {
42
- return config;
43
- }