@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 +4 -0
- package/bin/apifuse-submit-check.ts +48 -10
- package/bin/submit-check-xml-semantics.ts +204 -0
- package/bin/submit-check-xml.ts +134 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +50 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/executor.js +7 -2
- package/dist/server/serve.js +15 -10
- package/package.json +2 -1
- package/src/errors.ts +60 -0
- package/src/provider.ts +3 -0
- package/src/runtime/executor.ts +7 -2
- package/src/server/serve.ts +18 -12
package/CHANGELOG.md
CHANGED
|
@@ -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"
|
|
2070
|
-
|
|
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
|
|
2195
|
-
if (!
|
|
2201
|
+
const member = numberedFamilyMember(key.name);
|
|
2202
|
+
if (!member) {
|
|
2196
2203
|
continue;
|
|
2197
2204
|
}
|
|
2198
|
-
const
|
|
2199
|
-
|
|
2200
|
-
digitFamilies.set(
|
|
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 (
|
|
2212
|
+
if (!isAllowedPublicOutputKeyName(key.name)) {
|
|
2206
2213
|
return true;
|
|
2207
2214
|
}
|
|
2208
|
-
const
|
|
2209
|
-
return
|
|
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 });
|
package/dist/provider.d.ts
CHANGED
|
@@ -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";
|
package/dist/runtime/executor.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/server/serve.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
421
|
+
if (isTransportError(error)) {
|
|
417
422
|
return error.code === "transport_timeout" ? 504 : 502;
|
|
418
423
|
}
|
|
419
|
-
if (error
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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
package/src/runtime/executor.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/server/serve.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
655
|
+
if (isTransportError(error)) {
|
|
650
656
|
return error.code === "transport_timeout" ? 504 : 502;
|
|
651
657
|
}
|
|
652
658
|
|
|
653
|
-
if (error
|
|
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
|
|
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
|
|
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
|
|
731
|
+
...(isTransportError(error) && error.upstreamStatus
|
|
726
732
|
? { upstreamStatus: error.upstreamStatus }
|
|
727
733
|
: {}),
|
|
728
734
|
...(details
|