@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.3
4
+
5
+ - Release candidate for main commit 74e8e18b502dd9b02dbf0d3e702f917570312fc0.
6
+
3
7
  ## 2.2.0-beta.2
4
8
 
5
9
  - Release candidate for main commit ceefad020a1038eade542fd3b128667b39625f6f.
@@ -20,6 +20,7 @@ import {
20
20
  import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
21
21
  import { safeParseSchemaSync } from "../src/schema";
22
22
  import { type CheckResult, runChecks } from "./apifuse-check";
23
+ import { hasSubstantiveXmlStructure } from "./submit-check-xml";
23
24
 
24
25
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
25
26
  const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
@@ -2066,8 +2067,14 @@ function recordedFixtureStats(
2066
2067
  leafValues,
2067
2068
  };
2068
2069
  }
2069
- if (typeof value === "string" && value.length === 0) {
2070
- return { hasNestedSubstance: false, leafValues: 0 };
2070
+ if (typeof value === "string") {
2071
+ if (value.length === 0) {
2072
+ return { hasNestedSubstance: false, leafValues: 0 };
2073
+ }
2074
+ // A recorded operation value may be a raw XML success payload; treat a
2075
+ // substantive, well-formed one as nested evidence while still counting the
2076
+ // string as a leaf so existing JSON provenance heuristics are unchanged.
2077
+ return { hasNestedSubstance: hasSubstantiveXmlStructure(value), leafValues: 1 };
2071
2078
  }
2072
2079
  return { hasNestedSubstance: false, leafValues: 1 };
2073
2080
  }
@@ -2191,26 +2198,57 @@ function vendorKeyFindingsForObject(
2191
2198
  const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
2192
2199
  const digitFamilies = new Map<string, Set<string>>();
2193
2200
  for (const key of keys) {
2194
- const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2195
- if (!digitMatch?.[1] || !digitMatch[2]) {
2201
+ const member = numberedFamilyMember(key.name);
2202
+ if (!member) {
2196
2203
  continue;
2197
2204
  }
2198
- const digits = digitFamilies.get(digitMatch[1]) ?? new Set<string>();
2199
- digits.add(digitMatch[2]);
2200
- digitFamilies.set(digitMatch[1], digits);
2205
+ const positions = digitFamilies.get(member.base) ?? new Set<string>();
2206
+ positions.add(member.position);
2207
+ digitFamilies.set(member.base, positions);
2201
2208
  }
2202
2209
 
2203
2210
  return keys
2204
2211
  .filter((key) => {
2205
- if (!/^[a-z][a-zA-Z0-9]*$/.test(key.name)) {
2212
+ if (!isAllowedPublicOutputKeyName(key.name)) {
2206
2213
  return true;
2207
2214
  }
2208
- const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2209
- return digitMatch?.[1] !== undefined && (digitFamilies.get(digitMatch[1])?.size ?? 0) >= 3;
2215
+ const member = numberedFamilyMember(key.name);
2216
+ return member !== null && (digitFamilies.get(member.base)?.size ?? 0) >= 3;
2210
2217
  })
2211
2218
  .map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
2212
2219
  }
2213
2220
 
2221
+ // A numbered vendor family is a base name plus a numeric position and an
2222
+ // optional trailing letter suffix, in either compact/camel form (sensor1,
2223
+ // duty1s) or semantic snake_case form (sensor_1, duty_time_1s). Both styles
2224
+ // normalize to the same { base, position } so a family of >=3 distinct
2225
+ // positions is caught regardless of which naming style the vendor leaked
2226
+ // through. Returns null for names that carry no numeric position.
2227
+ function numberedFamilyMember(name: string): { base: string; position: string } | null {
2228
+ const camelMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(name);
2229
+ if (camelMatch?.[1] && camelMatch[2]) {
2230
+ return { base: camelMatch[1], position: camelMatch[2] };
2231
+ }
2232
+ const snakeMatch = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*?)_(\d+)[a-z]*$/.exec(name);
2233
+ if (snakeMatch?.[1] && snakeMatch[2]) {
2234
+ return { base: snakeMatch[1], position: snakeMatch[2] };
2235
+ }
2236
+ return null;
2237
+ }
2238
+
2239
+ // Public output keys may use APIFuse lowerCamelCase (isOpen24h, latitude) or
2240
+ // semantic snake_case (pharmacy_id, weekly_hours, total_count, scan_exhausted).
2241
+ // Both are normalized, human-authored names. Raw vendor keys leak through mixed
2242
+ // case or uppercase acronyms (MKioskTy) and match neither, so they stay flagged.
2243
+ // Numbered vendor families still pass this name gate in either style
2244
+ // (sensor1/2/3 or sensor_1/sensor_2/sensor_3), so they are caught separately by
2245
+ // the >=3-member numberedFamilyMember check in vendorKeyFindingsForObject.
2246
+ function isAllowedPublicOutputKeyName(name: string): boolean {
2247
+ const isLowerCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(name);
2248
+ const isSemanticSnakeCase = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
2249
+ return isLowerCamelCase || isSemanticSnakeCase;
2250
+ }
2251
+
2214
2252
  function collectTopLevelObjectKeys(
2215
2253
  source: string,
2216
2254
  objectStart: number,
@@ -0,0 +1,204 @@
1
+ import type { XmlElement } from "@rgrove/parse-xml";
2
+
3
+ const FAILURE_TEXT_PATTERN =
4
+ /\b(?:access denied|denied|error|exception|failed|failure|fault|forbidden|invalid|maintenance|not authorized|temporarily unavailable|unauthorized|unavailable)\b/i;
5
+ const KOREAN_FAILURE_TEXT_PATTERN =
6
+ /(?:오류|에러|실패|장애|점검|서비스\s*(?:중단|불가)|(?:일시적(?:으로)?\s*)?(?:이용|사용)\s*(?:이|가)?\s*(?:불가|어렵|할\s*수\s*없))/u;
7
+ const SUCCESS_CODE_PATTERN = /^(?:0+|2\d\d|2xx|ok|success|successful|normalservice)$/;
8
+ const SUCCESS_VALUE_PATTERN = /^(?:1|true|y|yes|ok|success|successful)$/;
9
+ const SUCCESS_TEXT_PATTERN = /^(?:normalserviceresponse|successfulresponse)$/;
10
+ const LOCALIZED_SUCCESS_TEXT_PATTERN =
11
+ /^(?:成功|正常|処理完了|正常終了|処理が完了しました|성공|정상|처리완료|처리가완료되었습니다|处理完成|處理完成|操作成功)$/u;
12
+ const CODE_SHAPED_VALUE_PATTERN = /^(?:\d+|[1-5]xx)$/;
13
+ const CODE_CONTROL_FIELDS: ReadonlySet<string> = new Set([
14
+ "httpstatus",
15
+ "resultcode",
16
+ "returnreasoncode",
17
+ "statuscode",
18
+ ]);
19
+ const TEXT_CONTROL_FIELDS: ReadonlySet<string> = new Set([
20
+ "message",
21
+ "msg",
22
+ "reason",
23
+ "resultmessage",
24
+ "resultmsg",
25
+ "state",
26
+ "status",
27
+ "statustext",
28
+ ]);
29
+ const SUCCESS_CONTROL_FIELDS: ReadonlySet<string> = new Set([
30
+ "issuccess",
31
+ "ok",
32
+ "success",
33
+ "successful",
34
+ ]);
35
+ const ERROR_CODE_FIELD_PATTERN = /^(?:(?:error|exception|fault)(?:code|status)s?|errcode)$/;
36
+ const ERROR_TEXT_FIELD_PATTERN =
37
+ /^(?:(?:error|exception|fault)(?:description|detail|details|info|message|reason|string|type)?s?|errmsg|returnauthmsg)$/;
38
+ const STRONG_CONTROL_CONTEXT_NAMES: ReadonlySet<string> = new Set([
39
+ "cmmmsgheader",
40
+ "control",
41
+ "error",
42
+ "exception",
43
+ "fault",
44
+ "header",
45
+ "meta",
46
+ "result",
47
+ "status",
48
+ ]);
49
+ const ORDINARY_ENVELOPE_NAMES: ReadonlySet<string> = new Set(["body", "envelope", "response"]);
50
+ const ERROR_ROOT_NAMES: ReadonlySet<string> = new Set([
51
+ "error",
52
+ "errorresponse",
53
+ "exception",
54
+ "exceptionresponse",
55
+ "fault",
56
+ "faultresponse",
57
+ ]);
58
+ const DOMAIN_BOUNDARY_NAMES: ReadonlySet<string> = new Set([
59
+ "entry",
60
+ "item",
61
+ "measurement",
62
+ "record",
63
+ "row",
64
+ ]);
65
+
66
+ export type XmlSemanticBranch = "control" | "domain" | "envelope" | "error" | "neutral";
67
+
68
+ // A control failure is only meaningful in a control/error/envelope context.
69
+ // Inside a domain boundary (item/record/row/…) the same field names are ordinary
70
+ // data — e.g. `faultCode` describing a charger's fault is not a service failure.
71
+ export function hasSemanticXmlFailure(element: XmlElement, branch: XmlSemanticBranch): boolean {
72
+ if (branch === "domain") return false;
73
+ const insideError = branch === "error";
74
+ const strongControl = insideError || branch === "control";
75
+ const insideControl = strongControl || branch === "envelope";
76
+ const fieldName = normalizedXmlName(element.name);
77
+ const value = element.text.trim();
78
+ if (
79
+ hasControlValueFailure({
80
+ fieldName,
81
+ value,
82
+ insideControl: insideControl || isSemanticControlField(fieldName),
83
+ strongControl,
84
+ })
85
+ ) {
86
+ return true;
87
+ }
88
+ return Object.entries(element.attributes).some(([name, attributeValue]) =>
89
+ hasControlValueFailure({
90
+ fieldName: normalizedXmlName(name),
91
+ value: attributeValue.trim(),
92
+ insideControl: true,
93
+ strongControl: true,
94
+ }),
95
+ );
96
+ }
97
+
98
+ export function rootXmlContext(name: string): XmlSemanticBranch {
99
+ if (DOMAIN_BOUNDARY_NAMES.has(name)) return "domain";
100
+ if (isXmlErrorWrapperName(name)) return "error";
101
+ if (isStrongControlContextName(name)) return "control";
102
+ return ORDINARY_ENVELOPE_NAMES.has(name) ? "envelope" : "neutral";
103
+ }
104
+
105
+ export function childXmlContext(parent: XmlSemanticBranch, name: string): XmlSemanticBranch {
106
+ if (parent === "control" || parent === "domain" || parent === "error") return parent;
107
+ if (isXmlErrorWrapperName(name)) return "error";
108
+ if (isStrongControlContextName(name)) return "control";
109
+ if (DOMAIN_BOUNDARY_NAMES.has(name)) return "domain";
110
+ return parent === "envelope" || ORDINARY_ENVELOPE_NAMES.has(name) ? "envelope" : "neutral";
111
+ }
112
+
113
+ export function isXmlErrorRootName(name: string): boolean {
114
+ return ERROR_ROOT_NAMES.has(name) || isXmlErrorWrapperName(name);
115
+ }
116
+
117
+ export function normalizedXmlName(name: string): string {
118
+ const compatibleName = name.normalize("NFKC");
119
+ const localName = compatibleName.slice(compatibleName.lastIndexOf(":") + 1);
120
+ return localName.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
121
+ }
122
+
123
+ function hasControlValueFailure(input: {
124
+ readonly fieldName: string;
125
+ readonly value: string;
126
+ readonly insideControl: boolean;
127
+ readonly strongControl: boolean;
128
+ }): boolean {
129
+ const { fieldName, value, insideControl, strongControl } = input;
130
+ const normalizedValue = normalizedXmlValue(value);
131
+ if (ERROR_CODE_FIELD_PATTERN.test(fieldName)) {
132
+ return !SUCCESS_CODE_PATTERN.test(normalizedValue);
133
+ }
134
+ if (ERROR_TEXT_FIELD_PATTERN.test(fieldName)) {
135
+ return normalizedValue.length > 0 && !SUCCESS_CODE_PATTERN.test(normalizedValue);
136
+ }
137
+ if (
138
+ strongControl &&
139
+ TEXT_CONTROL_FIELDS.has(fieldName) &&
140
+ normalizedValue.length > 0 &&
141
+ !isExplicitSuccess(normalizedValue)
142
+ ) {
143
+ return true;
144
+ }
145
+ const isCodeControl =
146
+ CODE_CONTROL_FIELDS.has(fieldName) ||
147
+ (fieldName === "code" && insideControl) ||
148
+ (fieldName === "status" && insideControl && CODE_SHAPED_VALUE_PATTERN.test(normalizedValue));
149
+ if (isCodeControl && !SUCCESS_CODE_PATTERN.test(normalizedValue)) return true;
150
+ if (
151
+ insideControl &&
152
+ SUCCESS_CONTROL_FIELDS.has(fieldName) &&
153
+ !SUCCESS_VALUE_PATTERN.test(normalizedValue)
154
+ ) {
155
+ return true;
156
+ }
157
+ return insideControl && TEXT_CONTROL_FIELDS.has(fieldName) && hasFailureText(value);
158
+ }
159
+
160
+ function isSemanticControlField(fieldName: string): boolean {
161
+ return (
162
+ CODE_CONTROL_FIELDS.has(fieldName) ||
163
+ TEXT_CONTROL_FIELDS.has(fieldName) ||
164
+ SUCCESS_CONTROL_FIELDS.has(fieldName) ||
165
+ ERROR_CODE_FIELD_PATTERN.test(fieldName) ||
166
+ ERROR_TEXT_FIELD_PATTERN.test(fieldName) ||
167
+ fieldName === "code"
168
+ );
169
+ }
170
+
171
+ function isExplicitSuccess(value: string): boolean {
172
+ return (
173
+ SUCCESS_CODE_PATTERN.test(value) ||
174
+ SUCCESS_VALUE_PATTERN.test(value) ||
175
+ SUCCESS_TEXT_PATTERN.test(value) ||
176
+ LOCALIZED_SUCCESS_TEXT_PATTERN.test(value)
177
+ );
178
+ }
179
+
180
+ function isStrongControlContextName(name: string): boolean {
181
+ return STRONG_CONTROL_CONTEXT_NAMES.has(name) || name.endsWith("control");
182
+ }
183
+
184
+ function isXmlErrorWrapperName(name: string): boolean {
185
+ return /(?:error|exception|fault)(?:response)?$/.test(name);
186
+ }
187
+
188
+ function hasFailureText(value: string): boolean {
189
+ const normalized = value.normalize("NFKC");
190
+ return [normalized, normalized.replace(/\p{Cf}/gu, "")].some((candidate) => {
191
+ if (KOREAN_FAILURE_TEXT_PATTERN.test(candidate)) return true;
192
+ const tokenized = candidate
193
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
194
+ .replace(/[^A-Za-z0-9]+/g, " ");
195
+ return FAILURE_TEXT_PATTERN.test(tokenized);
196
+ });
197
+ }
198
+
199
+ function normalizedXmlValue(value: string): string {
200
+ return value
201
+ .normalize("NFKC")
202
+ .replace(/[^\p{L}\p{N}]/gu, "")
203
+ .toLowerCase();
204
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ parseXml,
3
+ XmlDocumentType,
4
+ XmlElement,
5
+ XmlError,
6
+ XmlProcessingInstruction,
7
+ } from "@rgrove/parse-xml";
8
+ import { Buffer } from "node:buffer";
9
+
10
+ import {
11
+ childXmlContext,
12
+ hasSemanticXmlFailure,
13
+ isXmlErrorRootName,
14
+ normalizedXmlName,
15
+ rootXmlContext,
16
+ type XmlSemanticBranch,
17
+ } from "./submit-check-xml-semantics";
18
+
19
+ const MIN_RECORDED_XML_LENGTH = 128;
20
+ // Recorded fixtures must remain reviewable; this pre-allocation cap also bounds the parser tree.
21
+ export const MAX_RECORDED_XML_BYTES = 4 * 1024 * 1024;
22
+ const MAX_RECORDED_XML_DEPTH = 64;
23
+ const MAX_RECORDED_XML_ELEMENTS = 50_000;
24
+ const XML_DOCTYPE_PATTERN = /<!DOCTYPE\b/i;
25
+ const REJECTED_RECORDED_XML_ROOT_NAMES: ReadonlySet<string> = new Set(["body", "html", "head"]);
26
+
27
+ // Recognizes a recorded operation value that is a substantive, well-formed XML
28
+ // success payload — the shape captured by `apifuse record` against upstreams
29
+ // that return XML (e.g. Korean public-data APIs). Fails closed on malformed XML,
30
+ // HTML, DTD/processing-instruction payloads, oversized/deep/wide trees, error
31
+ // roots, and failure/control-only envelopes. Uses a maintained parser rather
32
+ // than regex so entity/namespace/CDATA handling is correct.
33
+ export function hasSubstantiveXmlStructure(
34
+ value: string,
35
+ parser: typeof parseXml = parseXml,
36
+ ): boolean {
37
+ if (Buffer.byteLength(value, "utf8") > MAX_RECORDED_XML_BYTES) {
38
+ return false;
39
+ }
40
+ const xml = value.trim();
41
+ if (xml.length < MIN_RECORDED_XML_LENGTH || XML_DOCTYPE_PATTERN.test(xml)) {
42
+ return false;
43
+ }
44
+
45
+ let document: ReturnType<typeof parseXml>;
46
+ try {
47
+ document = parser(xml, { preserveDocumentType: true });
48
+ } catch (error) {
49
+ if (error instanceof XmlError || error instanceof RangeError) {
50
+ return false;
51
+ }
52
+ throw error;
53
+ }
54
+ if (
55
+ document.children.some(
56
+ (child) => child instanceof XmlDocumentType || child instanceof XmlProcessingInstruction,
57
+ )
58
+ ) {
59
+ return false;
60
+ }
61
+
62
+ const root = document.root;
63
+ if (root === null) {
64
+ return false;
65
+ }
66
+ const rootName = normalizedXmlName(root.name);
67
+ if (REJECTED_RECORDED_XML_ROOT_NAMES.has(rootName) || isXmlErrorRootName(rootName)) {
68
+ return false;
69
+ }
70
+
71
+ const pending: Array<{
72
+ readonly branch: XmlSemanticBranch;
73
+ readonly element: XmlElement;
74
+ readonly depth: number;
75
+ }> = [
76
+ {
77
+ element: root,
78
+ depth: 1,
79
+ branch: rootXmlContext(rootName),
80
+ },
81
+ ];
82
+ const leafNames = new Set<string>();
83
+ let leafTextLength = 0;
84
+ let elementCount = 0;
85
+ while (pending.length > 0) {
86
+ const current = pending.pop();
87
+ if (current === undefined) {
88
+ break;
89
+ }
90
+ elementCount += 1;
91
+ if (
92
+ elementCount > MAX_RECORDED_XML_ELEMENTS ||
93
+ current.depth > MAX_RECORDED_XML_DEPTH ||
94
+ hasSemanticXmlFailure(current.element, current.branch)
95
+ ) {
96
+ return false;
97
+ }
98
+
99
+ const childElements: XmlElement[] = [];
100
+ for (const child of current.element.children) {
101
+ if (child instanceof XmlProcessingInstruction) {
102
+ return false;
103
+ }
104
+ if (child instanceof XmlElement) {
105
+ childElements.push(child);
106
+ }
107
+ }
108
+ if (childElements.length === 0) {
109
+ const leafText = current.element.text.trim();
110
+ // Only substantive *domain* leaves count as evidence. Control/error
111
+ // leaves (resultCode, resultMsg, header status, …) are not payload data,
112
+ // so a control-only success envelope with no real records is rejected.
113
+ if (
114
+ leafText.length > 0 &&
115
+ current.depth >= 3 &&
116
+ current.branch !== "control" &&
117
+ current.branch !== "error"
118
+ ) {
119
+ leafNames.add(normalizedXmlName(current.element.name));
120
+ leafTextLength += leafText.length;
121
+ }
122
+ continue;
123
+ }
124
+ for (const child of childElements) {
125
+ const childName = normalizedXmlName(child.name);
126
+ pending.push({
127
+ element: child,
128
+ depth: current.depth + 1,
129
+ branch: childXmlContext(current.branch, childName),
130
+ });
131
+ }
132
+ }
133
+ return leafNames.size >= 2 && leafTextLength >= 16;
134
+ }
package/dist/errors.d.ts CHANGED
@@ -39,6 +39,9 @@ export declare class TransportError extends ProviderError {
39
39
  readonly upstreamStatus?: number;
40
40
  constructor(message: string, options?: TransportErrorOptions);
41
41
  }
42
+ export declare function isProviderError(value: unknown): value is ProviderError;
43
+ export declare function isSessionExpiredError(value: unknown): value is SessionExpiredError;
44
+ export declare function isTransportError(value: unknown): value is TransportError;
42
45
  export declare class ProviderSecretError extends ProviderError {
43
46
  constructor(message: string, options?: ProviderErrorOptions);
44
47
  }
package/dist/errors.js CHANGED
@@ -1,3 +1,37 @@
1
+ // Versioned, cross-realm brands. `Symbol.for` resolves to the same symbol in
2
+ // any copy/entrypoint of this SDK major version, so an error created by a
3
+ // duplicate module instance (e.g. the packaged CLI's src/* server vs a
4
+ // provider's dist/* import) still carries a brand the server can recognize even
5
+ // though `instanceof` splits across the two constructors. The `@1` suffix lets a
6
+ // future breaking change to this contract mint a distinct key.
7
+ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
8
+ const PROVIDER_ERROR_BRAND_VALUE = 1;
9
+ const SESSION_EXPIRED_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/session-expired@1");
10
+ const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
11
+ // Defines a non-enumerable, non-writable, non-configurable own data property.
12
+ // Immutable + own means a guard can trust it via a single descriptor read
13
+ // without invoking attacker-controlled getters or accepting inherited brands.
14
+ function defineErrorBrand(target, brand, value) {
15
+ Object.defineProperty(target, brand, {
16
+ value,
17
+ enumerable: false,
18
+ writable: false,
19
+ configurable: false,
20
+ });
21
+ }
22
+ // Recognizes an own data-property brand with the expected value. Rejects
23
+ // missing brands (unbranded lookalikes), accessor brands (no own `value`
24
+ // slot — the getter is never called), and inherited brands (own-descriptor
25
+ // lookup returns undefined on the child).
26
+ function hasOwnBrand(value, brand, expected) {
27
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) {
28
+ return false;
29
+ }
30
+ const descriptor = Object.getOwnPropertyDescriptor(value, brand);
31
+ return (descriptor !== undefined &&
32
+ Object.hasOwn(descriptor, "value") &&
33
+ descriptor.value === expected);
34
+ }
1
35
  export class ProviderError extends Error {
2
36
  options;
3
37
  constructor(message, options) {
@@ -7,6 +41,7 @@ export class ProviderError extends Error {
7
41
  if (options?.cause) {
8
42
  this.cause = options.cause;
9
43
  }
44
+ defineErrorBrand(this, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
10
45
  }
11
46
  get fix() {
12
47
  return this.options?.fix;
@@ -39,6 +74,7 @@ export class SessionExpiredError extends AuthError {
39
74
  ...options,
40
75
  });
41
76
  this.name = "SessionExpiredError";
77
+ defineErrorBrand(this, SESSION_EXPIRED_BRAND, true);
42
78
  }
43
79
  }
44
80
  export class ValidationError extends ProviderError {
@@ -57,8 +93,22 @@ export class TransportError extends ProviderError {
57
93
  this.name = "TransportError";
58
94
  this.status = options?.status;
59
95
  this.upstreamStatus = options?.upstreamStatus ?? options?.status;
96
+ defineErrorBrand(this, TRANSPORT_BRAND, true);
60
97
  }
61
98
  }
99
+ // Cross-module type guards. Prefer these over `instanceof` at any boundary that
100
+ // may receive an error from a different copy/entrypoint of the SDK (see the HTTP
101
+ // server error boundary). They recognize branded errors regardless of which
102
+ // module instance constructed them, while rejecting unbranded lookalikes.
103
+ export function isProviderError(value) {
104
+ return hasOwnBrand(value, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
105
+ }
106
+ export function isSessionExpiredError(value) {
107
+ return isProviderError(value) && hasOwnBrand(value, SESSION_EXPIRED_BRAND, true);
108
+ }
109
+ export function isTransportError(value) {
110
+ return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
111
+ }
62
112
  export class ProviderSecretError extends ProviderError {
63
113
  constructor(message, options) {
64
114
  super(message, { code: "provider_secret_error", ...options });
@@ -3,7 +3,7 @@ export type { CredentialsAuthChallengeDefinition, CredentialsAuthChallengeReques
3
3
  export { createFormCeremony } from "./ceremonies";
4
4
  export { assertFreshProviderChoiceIssuedAt, createProviderChoiceToken, ProviderChoiceTokenError, type ProviderChoiceTokenErrorReason, type ProviderChoiceTokenPayload, parseProviderChoiceToken, } from "./choice-token";
5
5
  export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, every, } from "./define";
6
- export { AuthError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors";
6
+ export { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors";
7
7
  export { getProviderLocalePath, providerLocaleKey, qualifyProviderLocaleKey, } from "./i18n";
8
8
  export { type CreateProviderChoiceContextOptions, createProviderChoiceContext, createTestProviderChoiceContext, PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV, } from "./runtime/choice";
9
9
  export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SENSITIVE_KIND_META_KEY, APIFUSE_SENSITIVE_META_KEY, collectSensitivePaths, describeKey, field, fields, isSensitiveSchema, redactPayload, type SensitiveFieldKind, type SensitiveFieldOptions, type SensitivePath, sensitive, z, } from "./schema";
package/dist/provider.js CHANGED
@@ -2,7 +2,7 @@ export { AuthAbortError, credentialsAuthChallenge, createAuthFlowHelpers, define
2
2
  export { createFormCeremony } from "./ceremonies";
3
3
  export { assertFreshProviderChoiceIssuedAt, createProviderChoiceToken, ProviderChoiceTokenError, parseProviderChoiceToken, } from "./choice-token";
4
4
  export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, every, } from "./define";
5
- export { AuthError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors";
5
+ export { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors";
6
6
  export { getProviderLocalePath, providerLocaleKey, qualifyProviderLocaleKey, } from "./i18n";
7
7
  export { createProviderChoiceContext, createTestProviderChoiceContext, PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV, } from "./runtime/choice";
8
8
  export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SENSITIVE_KIND_META_KEY, APIFUSE_SENSITIVE_META_KEY, collectSensitivePaths, describeKey, field, fields, isSensitiveSchema, redactPayload, sensitive, z, } from "./schema";
@@ -1,4 +1,4 @@
1
- import { ProviderError, SessionExpiredError } from "../errors";
1
+ import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors";
2
2
  import { parseSchema } from "../schema";
3
3
  export function isStreamingOperation(provider, operationId) {
4
4
  const kind = provider.operations[operationId]?.transport?.kind ?? "json";
@@ -39,7 +39,12 @@ export async function executeOperation(provider, operationId, ctx, input, _optio
39
39
  // operation is safe to re-drive after refresh, which we signal by marking
40
40
  // the surfaced error retryable; non-idempotent operations (the default)
41
41
  // stay non-retryable so they are not auto-re-driven. See design.md §4.3 D3.
42
- if (error instanceof SessionExpiredError && operation.retryOnAuthRefresh) {
42
+ // Use the branded guard, not `instanceof`: a handler loaded through a
43
+ // duplicate/published SDK module can throw a correctly branded
44
+ // SessionExpiredError whose constructor identity differs from this
45
+ // executor's, which `instanceof` would miss — dropping the retryable
46
+ // upgrade and stranding an operation that opted into auth refresh.
47
+ if (isSessionExpiredError(error) && operation.retryOnAuthRefresh) {
43
48
  throw new SessionExpiredError(error.message, { retryable: true });
44
49
  }
45
50
  throw error;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { AuthAbortError, createAuthFlowHelpers } from "../auth";
6
- import { AuthError, ProviderError, SessionExpiredError, TransportError, } from "../errors";
6
+ import { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, } from "../errors";
7
7
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog";
8
8
  import { categoryForStatus, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability";
9
9
  import { createScratchpad } from "../runtime/auth-flow";
@@ -293,7 +293,7 @@ function zodDetails(error) {
293
293
  }));
294
294
  }
295
295
  function toErrorResponse(error, requestId) {
296
- if (error instanceof ProviderError) {
296
+ if (isProviderError(error)) {
297
297
  const details = publicProviderErrorDetails(error);
298
298
  return {
299
299
  error: {
@@ -343,20 +343,25 @@ function publicProviderErrorDetails(error) {
343
343
  function isPlainRecord(value) {
344
344
  return value !== null && typeof value === "object" && !Array.isArray(value);
345
345
  }
346
+ // Accepts `unknown` so the branded guards narrow cleanly from the top: the
347
+ // subtype error classes are structurally compatible with ProviderError, so
348
+ // narrowing from a ProviderError-typed value would collapse the negative branch
349
+ // to `never`. Narrowing from unknown avoids that while still recognizing errors
350
+ // from a duplicate SDK module instance.
346
351
  function providerObservabilityDetails(error) {
347
352
  // Session-expiry surfaces the credential_expired category + the opt-in
348
353
  // retryable signal so Gateway/Credential Service can refresh and re-drive the
349
354
  // operation (see design.md §4.3 D3). Without this branch the auth error would
350
355
  // serialize as a bare 401 with no retryable/category, losing the refresh
351
356
  // signal for exactly the retryOnAuthRefresh operations it is meant to enable.
352
- if (error instanceof SessionExpiredError) {
357
+ if (isSessionExpiredError(error)) {
353
358
  return {
354
359
  category: error.options?.category ?? "credential_expired",
355
360
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
356
361
  retryable: error.options?.retryable ?? false,
357
362
  };
358
363
  }
359
- if (!(error instanceof TransportError)) {
364
+ if (!isTransportError(error)) {
360
365
  return undefined;
361
366
  }
362
367
  const isProxyPoolCode = error.code === PROXY_POOL_EXHAUSTED_CODE ||
@@ -385,7 +390,7 @@ function providerObservabilityDetails(error) {
385
390
  };
386
391
  }
387
392
  function publicProviderErrorMessage(error) {
388
- if (error instanceof TransportError) {
393
+ if (isTransportError(error)) {
389
394
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
390
395
  return error.message;
391
396
  }
@@ -413,10 +418,10 @@ function toStatusCode(error) {
413
418
  if (error instanceof z.ZodError) {
414
419
  return 400;
415
420
  }
416
- if (error instanceof TransportError) {
421
+ if (isTransportError(error)) {
417
422
  return error.code === "transport_timeout" ? 504 : 502;
418
423
  }
419
- if (error instanceof ProviderError) {
424
+ if (isProviderError(error)) {
420
425
  switch (error.code) {
421
426
  case "AUTH_REQUIRED":
422
427
  case "reauth_required":
@@ -448,14 +453,14 @@ function extractRequestId(raw) {
448
453
  return typeof value === "string" ? value : undefined;
449
454
  }
450
455
  function logProviderError(logger, provider, kind, route, requestId, error, status, cost) {
451
- const code = error instanceof ProviderError
456
+ const code = isProviderError(error)
452
457
  ? (error.code ?? "provider_error")
453
458
  : error instanceof z.ZodError
454
459
  ? "invalid_request"
455
460
  : "internal_error";
456
461
  const errorClass = error instanceof Error ? error.name : typeof error;
457
462
  const message = error instanceof Error ? error.message : String(error);
458
- const details = error instanceof ProviderError
463
+ const details = isProviderError(error)
459
464
  ? providerObservabilityDetails(error)
460
465
  : undefined;
461
466
  const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
@@ -471,7 +476,7 @@ function logProviderError(logger, provider, kind, route, requestId, error, statu
471
476
  code,
472
477
  errorClass,
473
478
  message,
474
- ...(error instanceof TransportError && error.upstreamStatus
479
+ ...(isTransportError(error) && error.upstreamStatus
475
480
  ? { upstreamStatus: error.upstreamStatus }
476
481
  : {}),
477
482
  ...(details
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.2",
2
+ "version": "2.2.0-beta.3",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -93,6 +93,7 @@
93
93
  },
94
94
  "dependencies": {
95
95
  "@clack/prompts": "^1.5.1",
96
+ "@rgrove/parse-xml": "4.2.2",
96
97
  "@types/ms": "^2.1.0",
97
98
  "acorn": "^8.17.0",
98
99
  "ajv": "^8.17",
package/src/errors.ts CHANGED
@@ -1,5 +1,46 @@
1
1
  import type { ProviderErrorCategory } from "./observability";
2
2
 
3
+ // Versioned, cross-realm brands. `Symbol.for` resolves to the same symbol in
4
+ // any copy/entrypoint of this SDK major version, so an error created by a
5
+ // duplicate module instance (e.g. the packaged CLI's src/* server vs a
6
+ // provider's dist/* import) still carries a brand the server can recognize even
7
+ // though `instanceof` splits across the two constructors. The `@1` suffix lets a
8
+ // future breaking change to this contract mint a distinct key.
9
+ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
10
+ const PROVIDER_ERROR_BRAND_VALUE = 1;
11
+ const SESSION_EXPIRED_BRAND = Symbol.for(
12
+ "@apifuse/provider-sdk/error-kind/session-expired@1",
13
+ );
14
+ const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
15
+
16
+ // Defines a non-enumerable, non-writable, non-configurable own data property.
17
+ // Immutable + own means a guard can trust it via a single descriptor read
18
+ // without invoking attacker-controlled getters or accepting inherited brands.
19
+ function defineErrorBrand(target: object, brand: symbol, value: number | true): void {
20
+ Object.defineProperty(target, brand, {
21
+ value,
22
+ enumerable: false,
23
+ writable: false,
24
+ configurable: false,
25
+ });
26
+ }
27
+
28
+ // Recognizes an own data-property brand with the expected value. Rejects
29
+ // missing brands (unbranded lookalikes), accessor brands (no own `value`
30
+ // slot — the getter is never called), and inherited brands (own-descriptor
31
+ // lookup returns undefined on the child).
32
+ function hasOwnBrand(value: unknown, brand: symbol, expected: number | true): boolean {
33
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) {
34
+ return false;
35
+ }
36
+ const descriptor = Object.getOwnPropertyDescriptor(value, brand);
37
+ return (
38
+ descriptor !== undefined &&
39
+ Object.hasOwn(descriptor, "value") &&
40
+ descriptor.value === expected
41
+ );
42
+ }
43
+
3
44
  export type ProviderErrorOptions = {
4
45
  fix?: string;
5
46
  code?: string;
@@ -19,6 +60,7 @@ export class ProviderError extends Error {
19
60
  if (options?.cause) {
20
61
  this.cause = options.cause;
21
62
  }
63
+ defineErrorBrand(this, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
22
64
  }
23
65
 
24
66
  get fix(): string | undefined {
@@ -60,6 +102,7 @@ export class SessionExpiredError extends AuthError {
60
102
  ...options,
61
103
  });
62
104
  this.name = "SessionExpiredError";
105
+ defineErrorBrand(this, SESSION_EXPIRED_BRAND, true);
63
106
  }
64
107
  }
65
108
 
@@ -91,9 +134,26 @@ export class TransportError extends ProviderError {
91
134
  this.name = "TransportError";
92
135
  this.status = options?.status;
93
136
  this.upstreamStatus = options?.upstreamStatus ?? options?.status;
137
+ defineErrorBrand(this, TRANSPORT_BRAND, true);
94
138
  }
95
139
  }
96
140
 
141
+ // Cross-module type guards. Prefer these over `instanceof` at any boundary that
142
+ // may receive an error from a different copy/entrypoint of the SDK (see the HTTP
143
+ // server error boundary). They recognize branded errors regardless of which
144
+ // module instance constructed them, while rejecting unbranded lookalikes.
145
+ export function isProviderError(value: unknown): value is ProviderError {
146
+ return hasOwnBrand(value, PROVIDER_ERROR_BRAND, PROVIDER_ERROR_BRAND_VALUE);
147
+ }
148
+
149
+ export function isSessionExpiredError(value: unknown): value is SessionExpiredError {
150
+ return isProviderError(value) && hasOwnBrand(value, SESSION_EXPIRED_BRAND, true);
151
+ }
152
+
153
+ export function isTransportError(value: unknown): value is TransportError {
154
+ return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
155
+ }
156
+
97
157
  export class ProviderSecretError extends ProviderError {
98
158
  constructor(message: string, options?: ProviderErrorOptions) {
99
159
  super(message, { code: "provider_secret_error", ...options });
package/src/provider.ts CHANGED
@@ -37,6 +37,9 @@ export {
37
37
  } from "./define";
38
38
  export {
39
39
  AuthError,
40
+ isProviderError,
41
+ isSessionExpiredError,
42
+ isTransportError,
40
43
  ProviderError,
41
44
  SessionExpiredError,
42
45
  TransportError,
@@ -1,4 +1,4 @@
1
- import { ProviderError, SessionExpiredError } from "../errors";
1
+ import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors";
2
2
  import { parseSchema } from "../schema";
3
3
  import type { ProviderContext, ProviderDefinition } from "../types";
4
4
 
@@ -64,7 +64,12 @@ export async function executeOperation(
64
64
  // operation is safe to re-drive after refresh, which we signal by marking
65
65
  // the surfaced error retryable; non-idempotent operations (the default)
66
66
  // stay non-retryable so they are not auto-re-driven. See design.md §4.3 D3.
67
- if (error instanceof SessionExpiredError && operation.retryOnAuthRefresh) {
67
+ // Use the branded guard, not `instanceof`: a handler loaded through a
68
+ // duplicate/published SDK module can throw a correctly branded
69
+ // SessionExpiredError whose constructor identity differs from this
70
+ // executor's, which `instanceof` would miss — dropping the retryable
71
+ // upgrade and stranding an operation that opted into auth refresh.
72
+ if (isSessionExpiredError(error) && operation.retryOnAuthRefresh) {
68
73
  throw new SessionExpiredError(error.message, { retryable: true });
69
74
  }
70
75
  throw error;
@@ -6,9 +6,10 @@ import { z } from "zod";
6
6
  import { AuthAbortError, createAuthFlowHelpers } from "../auth";
7
7
  import {
8
8
  AuthError,
9
+ isProviderError,
10
+ isSessionExpiredError,
11
+ isTransportError,
9
12
  ProviderError,
10
- SessionExpiredError,
11
- TransportError,
12
13
  } from "../errors";
13
14
  import {
14
15
  loadProviderLocaleCatalogs,
@@ -507,7 +508,7 @@ function toErrorResponse(
507
508
  error: unknown,
508
509
  requestId?: string,
509
510
  ): OperationErrorResponse {
510
- if (error instanceof ProviderError) {
511
+ if (isProviderError(error)) {
511
512
  const details = publicProviderErrorDetails(error);
512
513
  return {
513
514
  error: {
@@ -563,7 +564,12 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
563
564
  return value !== null && typeof value === "object" && !Array.isArray(value);
564
565
  }
565
566
 
566
- function providerObservabilityDetails(error: ProviderError):
567
+ // Accepts `unknown` so the branded guards narrow cleanly from the top: the
568
+ // subtype error classes are structurally compatible with ProviderError, so
569
+ // narrowing from a ProviderError-typed value would collapse the negative branch
570
+ // to `never`. Narrowing from unknown avoids that while still recognizing errors
571
+ // from a duplicate SDK module instance.
572
+ function providerObservabilityDetails(error: unknown):
567
573
  | {
568
574
  category: ProviderErrorCategory;
569
575
  taxonomyVersion: string;
@@ -576,14 +582,14 @@ function providerObservabilityDetails(error: ProviderError):
576
582
  // operation (see design.md §4.3 D3). Without this branch the auth error would
577
583
  // serialize as a bare 401 with no retryable/category, losing the refresh
578
584
  // signal for exactly the retryOnAuthRefresh operations it is meant to enable.
579
- if (error instanceof SessionExpiredError) {
585
+ if (isSessionExpiredError(error)) {
580
586
  return {
581
587
  category: error.options?.category ?? "credential_expired",
582
588
  taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
583
589
  retryable: error.options?.retryable ?? false,
584
590
  };
585
591
  }
586
- if (!(error instanceof TransportError)) {
592
+ if (!isTransportError(error)) {
587
593
  return undefined;
588
594
  }
589
595
  const isProxyPoolCode =
@@ -616,7 +622,7 @@ function providerObservabilityDetails(error: ProviderError):
616
622
  }
617
623
 
618
624
  function publicProviderErrorMessage(error: ProviderError): string {
619
- if (error instanceof TransportError) {
625
+ if (isTransportError(error)) {
620
626
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
621
627
  return error.message;
622
628
  }
@@ -646,11 +652,11 @@ function toStatusCode(
646
652
  return 400;
647
653
  }
648
654
 
649
- if (error instanceof TransportError) {
655
+ if (isTransportError(error)) {
650
656
  return error.code === "transport_timeout" ? 504 : 502;
651
657
  }
652
658
 
653
- if (error instanceof ProviderError) {
659
+ if (isProviderError(error)) {
654
660
  switch (error.code) {
655
661
  case "AUTH_REQUIRED":
656
662
  case "reauth_required":
@@ -697,7 +703,7 @@ function logProviderError(
697
703
  cost: ProviderRequestCost,
698
704
  ): void {
699
705
  const code =
700
- error instanceof ProviderError
706
+ isProviderError(error)
701
707
  ? (error.code ?? "provider_error")
702
708
  : error instanceof z.ZodError
703
709
  ? "invalid_request"
@@ -705,7 +711,7 @@ function logProviderError(
705
711
  const errorClass = error instanceof Error ? error.name : typeof error;
706
712
  const message = error instanceof Error ? error.message : String(error);
707
713
  const details =
708
- error instanceof ProviderError
714
+ isProviderError(error)
709
715
  ? providerObservabilityDetails(error)
710
716
  : undefined;
711
717
  const emit =
@@ -722,7 +728,7 @@ function logProviderError(
722
728
  code,
723
729
  errorClass,
724
730
  message,
725
- ...(error instanceof TransportError && error.upstreamStatus
731
+ ...(isTransportError(error) && error.upstreamStatus
726
732
  ? { upstreamStatus: error.upstreamStatus }
727
733
  : {}),
728
734
  ...(details