@apifuse/provider-sdk 2.2.0-beta.63 → 2.2.0-beta.65

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
@@ -136,6 +136,8 @@ Hosted runtime settings are engine-owned as well: every `APIFUSE__CDP_POOL__*`
136
136
  variable, the Cloudflare OCR/STT tokens and account identifier,
137
137
  `APIFUSE__OCR__API_KEY`, and `APIFUSE__CACHE__KEY_PEPPER`. `defineProvider`
138
138
  rejects them in `secrets`; the engine reads them from its own environment.
139
+ Provider code must not probe those names either — to learn whether OCR is
140
+ configured, read `ctx.ocr.available` (see the OCR section below).
139
141
 
140
142
  ### Engine mode
141
143
 
@@ -455,6 +457,40 @@ Best-practice rules:
455
457
  - Submission checks and health checks must not invoke live STT by default.
456
458
  Provide explicit smoke evidence when a provider depends on audio OTP behavior.
457
459
 
460
+ ### OCR runtime capability: availability is the SDK's verdict
461
+
462
+ Declare `ocr: { mode: "required" | "optional" }` and call `ctx.ocr` from
463
+ operation handlers or auth-flow handlers. Whether a working backend is wired
464
+ in is decided by the SDK, never by provider code: `ctx.ocr.available` is
465
+ `true` when `createOcrClientFromEnv` found a configured backend and `false`
466
+ for the unsupported/error client that undeclared or misconfigured providers
467
+ receive. Branch on that flag for behavior that depends on OCR being present
468
+ (for example, whether a CAPTCHA field is required from the user), and keep the
469
+ `OCR_UNAVAILABLE` / `UNSUPPORTED_OCR_BACKEND` error fallback for the call
470
+ itself: `available: true` promises a configured backend, not a successful
471
+ upstream call.
472
+
473
+ ```ts
474
+ async start(ctx) {
475
+ const image = await fetchCaptcha(ctx);
476
+ return ctx.auth.nextForm({
477
+ turnId: "credentials",
478
+ fields: {
479
+ captchaText: { type: "string", required: !ctx.ocr.available, /* ... */ },
480
+ },
481
+ data: { captchaImage: image, ocrConfigured: ctx.ocr.available },
482
+ });
483
+ }
484
+ ```
485
+
486
+ Do not re-implement the predicate by reading `APIFUSE__OCR__*` or
487
+ `APIFUSE__CLOUDFLARE__ACCOUNT_ID`: those names are engine-owned, the
488
+ runtime-boundary lint reports the read, and a drift between the copy and the
489
+ SDK's own selection logic downgrades every login to the manual path silently.
490
+
491
+ A custom `OcrContext` override (`serve({ ocr })`, `createFlowContext({ ocr })`,
492
+ or a hand-written test double) must set `available` explicitly as a plain data
493
+ property; the SDK's own clients do, and `bindOcrTelemetry` preserves it.
458
494
 
459
495
  ### Health journey DX for SMS/payment flows
460
496
 
@@ -857,6 +893,77 @@ Note the asymmetry: the gate treats whitespace-only values as missing, but
857
893
  `ctx.env.get()` still returns the raw value to handlers — trim at the point of
858
894
  use if the upstream is whitespace-sensitive.
859
895
 
896
+ ### Runtime boundary: no `process.env`, `node:fs`, `child_process`, or raw `fetch()`
897
+
898
+ Provider runtime code reaches the outside world through the context only:
899
+ `ctx.http` / `ctx.stealth` for HTTP, `ctx.browser` for browser work,
900
+ `ctx.env.get()` / `ctx.credential` for configuration and secrets, `ctx.files` /
901
+ `ctx.cache` / `ctx.state` for data that must outlive a request. Ambient Node
902
+ and Bun globals bypass `allowedHosts`, proxy policy, retries, redaction, the
903
+ secret presence gate, and telemetry. `apifuse check` reports them at **error**
904
+ level — a finding fails `apifuse check` and `apifuse submit-check`, in the
905
+ official and the bounty `standalone` mode alike (the level was `warn` for one
906
+ release as a migration window; the window closed once the fleet's request-path
907
+ hits were fixed or acknowledged — a provider that still carries one sees
908
+ `apifuse check` fail on its next SDK bump and fixes or acknowledges it in that
909
+ same PR):
910
+
911
+ - `process-env-direct-read` — `process.env.X`, `process.env["X"]`, a bare
912
+ `process.env` object use, `Bun.env`. The `APIFUSE__RUNTIME__*` bootstrap
913
+ family (`APIFUSE__RUNTIME__PORT`, `…POD_ID`, `…POD_ENDPOINT`) is exempt
914
+ everywhere; every other value is declared in `defineProvider` under
915
+ `secrets: [{ name, required }]` and read with `ctx.env.get(name)`. The names
916
+ visible to `ctx.env.get` are exactly the declared `secrets[].name` (minus
917
+ engine-owned names; an `oauth2_proxied` auth flow additionally sees
918
+ `APIFUSE__AUTH_PROXY__URL`). `env: true` (or `env: {}`) is the capability
919
+ declaration that turns the `ctx.env` binding on — a type *and* runtime gate,
920
+ since touching `ctx.env` without it throws `PROVIDER_CAPABILITY_UNDECLARED`
921
+ — and does not widen the readable names. Test/E2E toggles are not secrets
922
+ and cannot move to `ctx.env`: replace them with test doubles (inject
923
+ `ctx.env` / `ctx.http`) or recorded fixtures.
924
+ - `node-runtime-module-import` — value imports of `fs`, `fs/promises`, `net`,
925
+ `tls`, `dgram`, `http`, `https`, `http2`, `child_process` (with or without
926
+ `node:`), and `Bun.spawn` / `Bun.spawnSync` / ``Bun.$` ` `` / `Bun.file` /
927
+ `Bun.write`. Type-only imports (`import type`, or every binding inline `type`) are ignored; `node:path`, `node:crypto`,
928
+ `node:url` are fine.
929
+ - `direct-fetch-call` — the global `fetch()` (also `globalThis.fetch`).
930
+ `ctx.stealth.fetch()` never matches, nor does a call to a `fetch` binding
931
+ declared in an enclosing scope (parameter, variable, import, catch clause).
932
+ The check is lexical: a wrapper that takes `fetch` as a parameter does not
933
+ hide a global `fetch()` in another function of the same file.
934
+
935
+ Scope is runtime source only: tests, recorded fixtures, `.d.ts`, the root
936
+ `dev.ts` / `start.ts` / `deploy.ts` entrypoints, and the `scripts/`, `tools/`,
937
+ `bin/` directories at the provider root are not scanned. Keep fixture
938
+ recorders, smoke scripts, and other operator tooling under `scripts/`, and do
939
+ not import from there in request-path modules. `apifuse check` also leaves out
940
+ any directory that is itself a git checkout (a linked worktree such as
941
+ `.worktree/<branch>/`, a nested clone, an initialised submodule): its files are
942
+ another revision, checked in that checkout, not this provider's runtime source.
943
+
944
+ Acknowledge a deliberate exception on the finding line or the comment-only
945
+ line above it with `// @apifuse-allow <rule>: <reason>`; the finding then
946
+ appears as an `INFO` audit line instead of an error, and the check passes.
947
+
948
+ ```ts
949
+ // Before: request-path module reads the ambient environment and the disk
950
+ import { mkdir, writeFile } from "node:fs/promises";
951
+ const serviceKey = process.env.EXAMPLE_SERVICE_KEY;
952
+ if (process.env.EXAMPLE_DIAG_TAP_DIR) {
953
+ await writeFile(`${process.env.EXAMPLE_DIAG_TAP_DIR}/booking.json`, JSON.stringify(response));
954
+ }
955
+
956
+ // After: the credential is a declared secret read through the context —
957
+ // defineProvider({
958
+ // env: true, // turns the ctx.env capability on (type + runtime gate)
959
+ // secrets: [{ name: "APIFUSE__PROVIDER__EXAMPLE__SERVICE_KEY", required: true }], // the readable names
960
+ // })
961
+ // — and the ad-hoc diagnostic tap is deleted: a toggle that is not a secret has
962
+ // no place in ctx.env; observability goes through ctx.trace instead.
963
+ const serviceKey = ctx.env.get("APIFUSE__PROVIDER__EXAMPLE__SERVICE_KEY");
964
+ const response = await ctx.trace.span("booking.confirm", () => confirmBooking(ctx, serviceKey));
965
+ ```
966
+
860
967
  ### Credentials forced into query parameters
861
968
 
862
969
  Prefer an authorization header or request body whenever the upstream supports
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.65
4
+
5
+ - Release candidate for main commit 4731ac76145caf85a6c340ad0c446371bd24b052.
6
+
7
+ ## 2.2.0-beta.64
8
+
9
+ - Release candidate for main commit 0505a938340d08db3ea3e2302cab5f6b5dd2b4e5.
10
+
3
11
  ## 2.2.0-beta.63
4
12
 
5
13
  - Release candidate for main commit a9b85acc5af4c40ec59774b0d1e864e7ed21828e.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vooy Korea, Inc. (주식회사 부이코리아)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
7
7
  import { z } from "zod";
@@ -226,6 +226,21 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
226
226
  );
227
227
  }
228
228
 
229
+ /**
230
+ * A directory under the provider root that is itself a git checkout — a linked
231
+ * worktree (`.worktree/<branch>/`, whose `.git` is a file pointing at the main
232
+ * repository), a nested clone (`.git` directory), or an initialised submodule.
233
+ * Its files are another revision of some repository, not this provider's
234
+ * runtime source: an older copy of `upstream/` or `scripts/` there would be
235
+ * linted as if it were request-path code at the root (the tooling-directory
236
+ * exemption is anchored at the provider root), and its `apifuse check` is run
237
+ * in that checkout, not here. Git refuses to track a path named `.git`, so the
238
+ * marker cannot be planted in a committed tree to hide a file from the scan.
239
+ */
240
+ function isNestedCheckout(directory: string): boolean {
241
+ return existsSync(join(directory, ".git"));
242
+ }
243
+
229
244
  function collectProviderSourceFiles(providerRoot: string): Record<string, string> {
230
245
  const sources: Record<string, string> = {};
231
246
  // `.agents`/`.apifuse` are deliberately not skipped: managed content there
@@ -236,7 +251,7 @@ function collectProviderSourceFiles(providerRoot: string): Record<string, string
236
251
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
237
252
  const path = resolve(directory, entry.name);
238
253
  if (entry.isDirectory()) {
239
- if (!skipDirectories.has(entry.name)) {
254
+ if (!skipDirectories.has(entry.name) && !isNestedCheckout(path)) {
240
255
  visit(path);
241
256
  }
242
257
  continue;
@@ -25,18 +25,22 @@ import {
25
25
  hasStreamEvidenceMarker,
26
26
  parseStreamEvidenceRecord,
27
27
  } from "../src/stream-evidence.js";
28
+ import { getTypeScript } from "../src/typescript-module.js";
28
29
  import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
29
30
  import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
30
31
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
31
32
 
32
- const ts: typeof import("typescript") = await loadTypeScript();
33
+ const ts: typeof import("typescript") = loadTypeScript();
33
34
 
34
- async function loadTypeScript(): Promise<typeof import("typescript")> {
35
+ // Shares the lint rules' loader so a workspace whose `typescript` is the 7.x
36
+ // native shell (no compiler API) falls back to `@typescript/typescript6`
37
+ // instead of failing inside the first parse.
38
+ function loadTypeScript(): typeof import("typescript") {
35
39
  try {
36
- return await import("typescript");
37
- } catch {
40
+ return getTypeScript();
41
+ } catch (error) {
38
42
  console.error(
39
- "apifuse submit-check requires typescript; install it in the workspace running the CLI (bun add -d typescript)",
43
+ `apifuse submit-check requires the TypeScript compiler API; install it in the workspace running the CLI (bun add -d typescript). ${error instanceof Error ? error.message : String(error)}`,
40
44
  );
41
45
  process.exit(1);
42
46
  }
@@ -15,25 +15,60 @@ export type HandleAccess = "bound" | "public";
15
15
  * `search-address` and `reverse-geocode` both issuing `location_token`).
16
16
  */
17
17
  export type HandleIssuedBy = string | readonly string[];
18
- /** Value stored under {@link APIFUSE_HANDLE_META_KEY} on a handle field. */
18
+ /** The schema side a handle field appears on. */
19
+ export type HandleFieldDirection = "input" | "output";
20
+ /**
21
+ * Direction-specific property keys for one handle kind, for the operations
22
+ * whose caller-facing contract names the same handle differently coming in and
23
+ * going out (the platform canon's `cursor` in, `next_cursor` out). The two keys
24
+ * are two spellings of ONE handle: the value a caller reads from `output` is
25
+ * the value it must send back under `input`, unedited.
26
+ */
27
+ export interface HandleFieldNames {
28
+ readonly input: string;
29
+ readonly output: string;
30
+ }
31
+ /**
32
+ * Value stored under {@link APIFUSE_HANDLE_META_KEY} on a handle field.
33
+ *
34
+ * `fieldName` is the INPUT property key — the name a caller sends the handle
35
+ * back under, and the name every runtime error message quotes. `outputFieldName`
36
+ * is present only when the output key differs, so a kind with one name emits
37
+ * exactly the meta it always did.
38
+ */
19
39
  export interface HandleFieldMeta {
20
40
  readonly kind: string;
21
41
  readonly type: HandleKindType;
22
42
  readonly fieldName: string;
43
+ readonly outputFieldName?: string;
23
44
  readonly issuedBy?: HandleIssuedBy;
24
45
  }
25
46
  /**
26
47
  * Structural view of a handle kind as seen by the provider declaration
27
48
  * (`handle: [WaitingDraft, PageCursor]`), lint, and the registry. The runtime
28
49
  * kinds returned by `defineCursor` / `defineDraft` satisfy this shape.
50
+ *
51
+ * `fieldName` / `outputFieldName` follow the {@link HandleFieldMeta} rule:
52
+ * `fieldName` is the input key, `outputFieldName` is set only when the output
53
+ * key differs.
29
54
  */
30
55
  export interface HandleKindDeclaration {
31
56
  readonly name: string;
32
57
  readonly type: HandleKindType;
33
58
  readonly fieldName: string;
59
+ readonly outputFieldName?: string;
34
60
  readonly access: HandleAccess;
35
61
  readonly issuedBy?: HandleIssuedBy;
36
62
  }
63
+ /**
64
+ * The property key a handle kind claims on one side of an operation.
65
+ *
66
+ * This is the single place that resolves a direction to a key, so lint, the
67
+ * runtime, and error text cannot disagree about which name belongs where.
68
+ */
69
+ export declare function handleFieldNameFor(kind: Pick<HandleKindDeclaration, "fieldName" | "outputFieldName">, direction: HandleFieldDirection): string;
70
+ /** True when a kind names the same handle differently on input and output. */
71
+ export declare function handleHasDirectionalFieldNames(kind: Pick<HandleKindDeclaration, "fieldName" | "outputFieldName">): boolean;
37
72
  /** Kind names are lowercase letters only so they never fuse with the word body. */
38
73
  export declare const HANDLE_KIND_NAME_PATTERN: RegExp;
39
74
  /** True for a single operation key or a non-empty list of operation keys. */
@@ -7,6 +7,21 @@
7
7
  */
8
8
  /** JSON Schema meta key placed on every schema field created by `kind.field()`. */
9
9
  export const APIFUSE_HANDLE_META_KEY = "x-apifuse-handle";
10
+ /**
11
+ * The property key a handle kind claims on one side of an operation.
12
+ *
13
+ * This is the single place that resolves a direction to a key, so lint, the
14
+ * runtime, and error text cannot disagree about which name belongs where.
15
+ */
16
+ export function handleFieldNameFor(kind, direction) {
17
+ if (direction === "input")
18
+ return kind.fieldName;
19
+ return kind.outputFieldName ?? kind.fieldName;
20
+ }
21
+ /** True when a kind names the same handle differently on input and output. */
22
+ export function handleHasDirectionalFieldNames(kind) {
23
+ return kind.outputFieldName !== undefined && kind.outputFieldName !== kind.fieldName;
24
+ }
10
25
  /** Kind names are lowercase letters only so they never fuse with the word body. */
11
26
  export const HANDLE_KIND_NAME_PATTERN = /^[a-z]{2,12}$/;
12
27
  /** True for a single operation key or a non-empty list of operation keys. */
@@ -43,6 +58,7 @@ export function isHandleFieldMeta(value) {
43
58
  return (typeof record.kind === "string" &&
44
59
  (record.type === "cursor" || record.type === "draft") &&
45
60
  typeof record.fieldName === "string" &&
61
+ (record.outputFieldName === undefined || typeof record.outputFieldName === "string") &&
46
62
  (record.issuedBy === undefined || isHandleIssuedBy(record.issuedBy)));
47
63
  }
48
64
  /**
package/dist/handle.d.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  */
10
10
  import { type ZodString, type ZodType, z } from "zod";
11
11
  import { ProviderError, type ProviderErrorOptions } from "./errors.js";
12
- import { type HandleAccess, type HandleFieldMeta, type HandleIssuedBy, type HandleKindDeclaration, type HandleKindType } from "./handle-meta.js";
12
+ import { type HandleAccess, type HandleFieldDirection, type HandleFieldMeta, type HandleFieldNames, type HandleIssuedBy, type HandleKindDeclaration, type HandleKindType } from "./handle-meta.js";
13
13
  import type { ProviderStateDurationString } from "./types.js";
14
- export type { HandleAccess, HandleFieldMeta, HandleIssuedBy, HandleKindDeclaration, HandleKindType, };
14
+ export type { HandleAccess, HandleFieldDirection, HandleFieldMeta, HandleFieldNames, HandleIssuedBy, HandleKindDeclaration, HandleKindType, };
15
15
  export type HandleStrength = "standard" | "high";
16
16
  /** Words issued per handle: 2 for bound kinds, 4 for public, 5 for high-strength public. */
17
17
  export type HandleWordCount = 2 | 4 | 5;
@@ -30,6 +30,10 @@ export interface HandleKindBase<TSchema extends ZodType = ZodType> extends Handl
30
30
  /**
31
31
  * Schema for the handle field, usable in output and input positions. Carries
32
32
  * the `x-apifuse-handle` meta and an SDK-owned English description.
33
+ *
34
+ * One schema serves both directions even when the kind declares separate
35
+ * `input` / `output` property keys: the meta carries both names and lint
36
+ * checks the key against the side the field actually appears on.
33
37
  */
34
38
  field(): ZodString;
35
39
  }
@@ -131,6 +135,7 @@ export declare function isHandleError(value: unknown): value is HandleError;
131
135
  */
132
136
  export declare function handleRecoverySentence(kind: {
133
137
  readonly fieldName: string;
138
+ readonly outputFieldName?: string;
134
139
  readonly issuedBy?: HandleIssuedBy;
135
140
  }): string;
136
141
  /** Parses a `ProviderStateDurationString` to milliseconds; throws on invalid or non-positive input. */
@@ -138,8 +143,16 @@ export declare function parseHandleDurationMs(value: ProviderStateDurationString
138
143
  export interface DefineCursorOptions<TSchema extends ZodType> {
139
144
  /** Kind name, `/^[a-z]{2,12}$/`; becomes the handle prefix. */
140
145
  readonly name: string;
141
- /** Schema property key carrying the handle. Default `${name}_token`. */
142
- readonly fieldName?: string;
146
+ /**
147
+ * Schema property key carrying the handle. Default `${name}_token`.
148
+ *
149
+ * Pass `{ input, output }` when the caller-facing contract names the same
150
+ * handle differently in each direction — the canon's `{ input: "cursor",
151
+ * output: "next_cursor" }`. Both keys must be given; the handle's identity
152
+ * stays the kind, so a value issued under `output` is still only valid when
153
+ * sent back under `input`.
154
+ */
155
+ readonly fieldName?: string | HandleFieldNames;
143
156
  readonly schema: TSchema;
144
157
  readonly ttl: ProviderStateDurationString;
145
158
  /** `"bound"` (default): connection-scoped, 2 words. `"public"`: provider-scoped, 4-5 words. */
@@ -155,7 +168,12 @@ export interface DefineCursorOptions<TSchema extends ZodType> {
155
168
  }
156
169
  export interface DefineDraftOptions<TSchema extends ZodType, TResultSchema extends ZodType | undefined> {
157
170
  readonly name: string;
158
- readonly fieldName?: string;
171
+ /**
172
+ * Schema property key carrying the handle. Default `${name}_token`. Accepts
173
+ * `{ input, output }` for a contract that names the handle differently in
174
+ * each direction; see {@link DefineCursorOptions.fieldName}.
175
+ */
176
+ readonly fieldName?: string | HandleFieldNames;
159
177
  readonly schema: TSchema;
160
178
  /** Optional schema of the commit result (type-level). */
161
179
  readonly result?: TResultSchema;
package/dist/handle.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { z } from "zod";
11
11
  import { brandHandleError, hasHandleErrorBrand, ProviderError, } from "./errors.js";
12
- import { APIFUSE_HANDLE_META_KEY, formatHandleIssuers, handleFieldDescription, HANDLE_KIND_NAME_PATTERN, isHandleIssuedBy, } from "./handle-meta.js";
12
+ import { APIFUSE_HANDLE_META_KEY, formatHandleIssuers, handleFieldDescription, handleHasDirectionalFieldNames, HANDLE_KIND_NAME_PATTERN, isHandleIssuedBy, } from "./handle-meta.js";
13
13
  import { BOUND_HANDLE_WORD_COUNT, HIGH_PUBLIC_HANDLE_WORD_COUNT, PUBLIC_HANDLE_WORD_COUNT, } from "./runtime/handle-wordlist.js";
14
14
  /** Public kinds whose (max) ttl exceeds this are issued with five words (auto-high). */
15
15
  const AUTO_HIGH_TTL_MS = 3_600_000;
@@ -64,6 +64,16 @@ export function isHandleError(value) {
64
64
  */
65
65
  export function handleRecoverySentence(kind) {
66
66
  const issuers = formatHandleIssuers(kind.issuedBy);
67
+ // When the kind is issued under one key and accepted under another, naming
68
+ // only one of them tells the LLM to look for a field that is not there. Name
69
+ // the output key it reads and the input key it writes, in that order.
70
+ if (handleHasDirectionalFieldNames(kind)) {
71
+ const issued = kind.outputFieldName;
72
+ if (issuers) {
73
+ return `Call ${issuers} again and pass the new \`${issued}\` back as \`${kind.fieldName}\`, exactly as returned.`;
74
+ }
75
+ return `Request a new \`${issued}\` and pass it back as \`${kind.fieldName}\`, exactly as returned.`;
76
+ }
67
77
  if (issuers) {
68
78
  return `Call ${issuers} again and pass the new \`${kind.fieldName}\` exactly as returned.`;
69
79
  }
@@ -111,10 +121,44 @@ function assertKindName(name, define) {
111
121
  throw new Error(`${define}: kind name must match ${HANDLE_KIND_NAME_PATTERN} (2-12 lowercase letters, no digits or separators) so it never fuses with the word body; received ${JSON.stringify(name)}.`);
112
122
  }
113
123
  }
114
- function assertFieldName(fieldName, define) {
115
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
116
- throw new Error(`${define}: fieldName must be a plain identifier usable as a schema property key; received ${JSON.stringify(fieldName)}.`);
124
+ function assertFieldName(fieldName, define, label) {
125
+ if (typeof fieldName !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
126
+ throw new Error(`${define}: ${label} must be a plain identifier usable as a schema property key; received ${JSON.stringify(fieldName)}.`);
127
+ }
128
+ }
129
+ /**
130
+ * Resolves the `fieldName` option to the input key and, when the two differ,
131
+ * the output key.
132
+ *
133
+ * The string form names one key for both directions and is unchanged. The
134
+ * object form requires BOTH keys: a half-declaration would leave one side
135
+ * silently defaulting to `${name}_token`, which is the kind of mismatch this
136
+ * option exists to make explicit. `outputFieldName` is left undefined when the
137
+ * two keys are equal so a one-name kind emits exactly the meta it always did.
138
+ */
139
+ function resolveFieldNames(value, define, name) {
140
+ if (value === undefined)
141
+ return { fieldName: `${name}_token` };
142
+ if (typeof value === "string") {
143
+ assertFieldName(value, define, "fieldName");
144
+ return { fieldName: value };
145
+ }
146
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
147
+ throw new Error(`${define}: "${name}" fieldName must be a property key or { input, output }; received ${JSON.stringify(value)}.`);
148
+ }
149
+ const record = value;
150
+ const extra = Object.keys(record).filter((key) => key !== "input" && key !== "output");
151
+ if (extra.length > 0) {
152
+ throw new Error(`${define}: "${name}" fieldName accepts only "input" and "output"; received extra ${extra.map((key) => JSON.stringify(key)).join(", ")}.`);
153
+ }
154
+ if (record.input === undefined || record.output === undefined) {
155
+ throw new Error(`${define}: "${name}" fieldName must declare both "input" and "output" when the two directions differ; declaring one would leave the other defaulting to "${name}_token".`);
117
156
  }
157
+ assertFieldName(record.input, define, "fieldName.input");
158
+ assertFieldName(record.output, define, "fieldName.output");
159
+ if (record.input === record.output)
160
+ return { fieldName: record.input };
161
+ return { fieldName: record.input, outputFieldName: record.output };
118
162
  }
119
163
  function assertPositiveInteger(value, label, define) {
120
164
  if (!Number.isInteger(value) || value <= 0) {
@@ -163,8 +207,7 @@ function createHandleField(meta) {
163
207
  export function defineCursor(options) {
164
208
  const define = "defineCursor";
165
209
  assertKindName(options.name, define);
166
- const fieldName = options.fieldName ?? `${options.name}_token`;
167
- assertFieldName(fieldName, define);
210
+ const { fieldName, outputFieldName } = resolveFieldNames(options.fieldName, define, options.name);
168
211
  const access = options.access ?? "bound";
169
212
  assertAccess(access, define);
170
213
  const ttlMs = parseHandleDurationMs(options.ttl, `"${options.name}" ttl`);
@@ -183,12 +226,14 @@ export function defineCursor(options) {
183
226
  kind: options.name,
184
227
  type: "cursor",
185
228
  fieldName,
229
+ ...(outputFieldName ? { outputFieldName } : {}),
186
230
  ...(issuedBy ? { issuedBy } : {}),
187
231
  };
188
232
  return Object.freeze({
189
233
  name: options.name,
190
234
  type: "cursor",
191
235
  fieldName,
236
+ outputFieldName,
192
237
  access,
193
238
  issuedBy,
194
239
  schema: options.schema,
@@ -209,8 +254,7 @@ export function defineCursor(options) {
209
254
  export function defineDraft(options) {
210
255
  const define = "defineDraft";
211
256
  assertKindName(options.name, define);
212
- const fieldName = options.fieldName ?? `${options.name}_token`;
213
- assertFieldName(fieldName, define);
257
+ const { fieldName, outputFieldName } = resolveFieldNames(options.fieldName, define, options.name);
214
258
  if (!options.ttl || typeof options.ttl !== "object") {
215
259
  throw new Error(`${define}: draft "${options.name}" ttl must be { idle, max }.`);
216
260
  }
@@ -242,12 +286,14 @@ export function defineDraft(options) {
242
286
  kind: options.name,
243
287
  type: "draft",
244
288
  fieldName,
289
+ ...(outputFieldName ? { outputFieldName } : {}),
245
290
  ...(issuedBy ? { issuedBy } : {}),
246
291
  };
247
292
  return Object.freeze({
248
293
  name: options.name,
249
294
  type: "draft",
250
295
  fieldName,
296
+ outputFieldName,
251
297
  access,
252
298
  strength,
253
299
  issuedBy,
@@ -1562,6 +1562,44 @@ export type OperationResult = {
1562
1562
  data: JsonValue;
1563
1563
  request_id: string;
1564
1564
  duration_ms: number;
1565
+ /**
1566
+ * Whether the monitor's operation step was answered from a stale-if-error
1567
+ * cache serve — the runtime's projection of the response envelope's
1568
+ * `meta.stale`.
1569
+ *
1570
+ * An operation that reads its upstream through `ctx.cache.getOrSet(...,
1571
+ * { staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
1572
+ * well-formed, fully schema-valid body for the whole stale window. Every
1573
+ * assertion over `status_code` and `data` therefore passes, and the probe
1574
+ * rolls up `ok` while the upstream is down. This boolean is the only thing
1575
+ * in the step result that separates that from a live read.
1576
+ *
1577
+ * Always present, so a reference to it resolves to `false` rather than
1578
+ * raising `reference_unresolvable`; a response whose `meta` is malformed
1579
+ * fails the step rather than projecting as fresh.
1580
+ *
1581
+ * Providers that serve stale-if-error should guard on it, so an outage the
1582
+ * cache is absorbing stops reading as green:
1583
+ *
1584
+ * ```ts
1585
+ * {
1586
+ * id: "guard-freshness",
1587
+ * result: "freshness-guarded",
1588
+ * kind: "guard",
1589
+ * condition: {
1590
+ * kind: "predicate",
1591
+ * operator: "not_equals",
1592
+ * actual: { ref: { namespace: "steps", binding: "case", path: ["served_stale_cache"] } },
1593
+ * expected: true,
1594
+ * },
1595
+ * onFail: {
1596
+ * attribute: [{ operationId, status: "degraded", reasonCode: "expected_absence", reasonKey }],
1597
+ * stop: "scenario",
1598
+ * },
1599
+ * }
1600
+ * ```
1601
+ */
1602
+ served_stale_cache: boolean;
1565
1603
  candidate?: {
1566
1604
  attempts: number;
1567
1605
  selected_index: number | null;
package/dist/index.d.ts CHANGED
@@ -13,14 +13,16 @@ export * from "./engine.js";
13
13
  export * from "./observability.js";
14
14
  export * from "./user-input.js";
15
15
  export * from "./i18n/index.js";
16
- export { type LintDiagnostic, lintOperation, lintProvider, } from "./lint.js";
16
+ export { type LintDiagnostic, lintOperation, lintProvider, type ProviderLintInformation, type ProviderLintResult, } from "./lint.js";
17
+ export { type LintRuntimeBoundarySourcesOptions, lintRuntimeBoundarySources, RUNTIME_BOUNDARY_RULES, type RuntimeBoundaryRule, } from "./runtime-boundary-lint.js";
18
+ export { TYPESCRIPT_COMPILER_MODULE_SPECIFIERS, type TypeScriptCompilerModuleLike, } from "./typescript-module.js";
17
19
  export * from "./recipes/gov-api.js";
18
20
  export * from "./recipes/rest-api.js";
19
21
  export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
20
22
  export type { BrowserClientOptions } from "./runtime/browser.js";
21
23
  export { APIFUSE__CACHE__KEY_PEPPER_ENV, createBypassProviderCache, createProviderCache, type ProviderCacheOptions, } from "./runtime/cache.js";
22
24
  export * from "./handle.js";
23
- export { APIFUSE_HANDLE_META_KEY, HANDLE_KIND_NAME_PATTERN, handleFieldDescription, isHandleFieldMeta, } from "./handle-meta.js";
25
+ export { APIFUSE_HANDLE_META_KEY, HANDLE_KIND_NAME_PATTERN, handleFieldDescription, handleFieldNameFor, handleHasDirectionalFieldNames, isHandleFieldMeta, } from "./handle-meta.js";
24
26
  export { type CreateHandleContextOptions, type CreateTestHandleContextOptions, createHandleContext, createTestHandleContext, type HandleNormalizationClass, type NormalizedHandle, normalizeHandle, } from "./runtime/handle.js";
25
27
  export { type CreateCredentialContextOptions, createCredentialContext, } from "./runtime/credential.js";
26
28
  export { createEnvContext } from "./runtime/env.js";
package/dist/index.js CHANGED
@@ -12,12 +12,14 @@ export * from "./observability.js";
12
12
  export * from "./user-input.js";
13
13
  export * from "./i18n/index.js";
14
14
  export { lintOperation, lintProvider, } from "./lint.js";
15
+ export { lintRuntimeBoundarySources, RUNTIME_BOUNDARY_RULES, } from "./runtime-boundary-lint.js";
16
+ export { TYPESCRIPT_COMPILER_MODULE_SPECIFIERS, } from "./typescript-module.js";
15
17
  export * from "./recipes/gov-api.js";
16
18
  export * from "./recipes/rest-api.js";
17
19
  export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
18
20
  export { APIFUSE__CACHE__KEY_PEPPER_ENV, createBypassProviderCache, createProviderCache, } from "./runtime/cache.js";
19
21
  export * from "./handle.js";
20
- export { APIFUSE_HANDLE_META_KEY, HANDLE_KIND_NAME_PATTERN, handleFieldDescription, isHandleFieldMeta, } from "./handle-meta.js";
22
+ export { APIFUSE_HANDLE_META_KEY, HANDLE_KIND_NAME_PATTERN, handleFieldDescription, handleFieldNameFor, handleHasDirectionalFieldNames, isHandleFieldMeta, } from "./handle-meta.js";
21
23
  export { createHandleContext, createTestHandleContext, normalizeHandle, } from "./runtime/handle.js";
22
24
  export { createCredentialContext, } from "./runtime/credential.js";
23
25
  export { createEnvContext } from "./runtime/env.js";
package/dist/lint.d.ts CHANGED
@@ -29,11 +29,16 @@ export type ProviderLintMode = "official" | "standalone";
29
29
  type ProviderLintOptions = {
30
30
  mode?: ProviderLintMode;
31
31
  };
32
+ /**
33
+ * A non-blocking audit line: a finding the author acknowledged with
34
+ * `// @apifuse-allow <rule>: <reason>`, kept visible in `apifuse check` output.
35
+ */
32
36
  export interface ProviderLintInformation {
33
37
  rule: string;
34
38
  message: string;
35
39
  field?: string;
36
40
  }
41
+ /** Diagnostics plus the acknowledged findings that accompany them. */
37
42
  export interface ProviderLintResult {
38
43
  diagnostics: LintDiagnostic[];
39
44
  information: ProviderLintInformation[];