@apifuse/provider-sdk 2.2.0-beta.1 → 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,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.3
4
+
5
+ - Release candidate for main commit 74e8e18b502dd9b02dbf0d3e702f917570312fc0.
6
+
7
+ ## 2.2.0-beta.2
8
+
9
+ - Release candidate for main commit ceefad020a1038eade542fd3b128667b39625f6f.
10
+
3
11
  ## 2.2.0-beta.1
4
12
 
5
13
  - Release candidate for main commit 5056b8c89fe0fa8f10bafcd30f83bcc421d4b5c5.
@@ -35,6 +43,7 @@
35
43
  ## Unreleased
36
44
 
37
45
  - Add `arrayBuffer()` and `bytes()` to `HttpResponse` so `ctx.http` consumers can read binary-safe upstream bodies; internal response handling is now byte-first.
46
+ - Preserve identity-only operation `connectionId` values in `ProviderContext` without requiring credential material.
38
47
 
39
48
  ## 2.1.0-beta.15
40
49
 
@@ -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;