@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 +9 -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/runtime/http.js +28 -338
- package/dist/runtime/proxy-retry-policy.d.ts +40 -0
- package/dist/runtime/proxy-retry-policy.js +326 -0
- package/dist/runtime/stealth.js +57 -203
- package/dist/server/serve.js +20 -12
- package/dist/server/types.d.ts +1 -0
- package/dist/server/types.js +1 -0
- 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/runtime/http.ts +60 -547
- package/src/runtime/proxy-retry-policy.ts +469 -0
- package/src/runtime/stealth.ts +100 -361
- package/src/server/serve.ts +26 -14
- package/src/server/types.ts +1 -0
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"
|
|
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;
|