@automate.ax/codec 0.84.0 → 0.86.1
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/dist/http.d.ts +16 -1
- package/dist/http.js +210 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sensitivity.d.ts +39 -0
- package/dist/sensitivity.js +65 -0
- package/package.json +4 -2
- package/src/http.ts +264 -12
- package/src/index.ts +8 -0
- package/src/sensitivity.ts +137 -0
package/dist/http.d.ts
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
import * as z from "zod/mini";
|
|
2
2
|
declare const JSON_VALUE_SCHEMA: z.ZodMiniJSONSchema;
|
|
3
3
|
export type JsonValue = z.infer<typeof JSON_VALUE_SCHEMA>;
|
|
4
|
-
export type
|
|
4
|
+
export type HttpRequestFormValue = File | string;
|
|
5
|
+
export type HttpRequestForm = Record<string, HttpRequestFormValue | HttpRequestFormValue[]>;
|
|
6
|
+
export type HttpRequestBody = HttpRequestForm | JsonValue | Uint8Array;
|
|
5
7
|
export type HttpRequestBodyType = "bytes" | "empty" | "form" | "json" | "text";
|
|
6
8
|
export type DecodedHttpRequestBody = {
|
|
7
9
|
body: HttpRequestBody;
|
|
8
10
|
bodyType: HttpRequestBodyType;
|
|
9
11
|
};
|
|
12
|
+
/** An HTTP request body that can't be decoded as its declared media type. */
|
|
13
|
+
export declare class HttpRequestBodyDecodeError extends Error {
|
|
14
|
+
readonly status: 400 | 413 | 415;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a request error with its public HTTP status.
|
|
17
|
+
*
|
|
18
|
+
* @param message - Safe response message.
|
|
19
|
+
* @param status - HTTP status returned by ingress.
|
|
20
|
+
* @param options - Optional underlying error details.
|
|
21
|
+
*/
|
|
22
|
+
constructor(message: string, status: 400 | 413 | 415, options?: ErrorOptions);
|
|
23
|
+
}
|
|
10
24
|
/**
|
|
11
25
|
* Decodes HTTP request bytes according to the declared media type.
|
|
12
26
|
*
|
|
13
27
|
* @param rawBody - Exact request body bytes.
|
|
14
28
|
* @param contentType - Declared Content-Type header, when present.
|
|
29
|
+
* @throws {HttpRequestBodyDecodeError} When a declared body can't be decoded.
|
|
15
30
|
*/
|
|
16
31
|
export declare function decodeHttpRequestBody(rawBody: Uint8Array, contentType?: string): DecodedHttpRequestBody;
|
|
17
32
|
export {};
|
package/dist/http.js
CHANGED
|
@@ -1,16 +1,56 @@
|
|
|
1
|
+
import { MaxFileSizeExceededError, MaxHeaderSizeExceededError, MaxPartsExceededError, MaxTotalSizeExceededError, MultipartParseError, parseMultipart, } from "@remix-run/multipart-parser";
|
|
2
|
+
import { parse as parseContentType } from "content-type";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
1
4
|
import * as z from "zod/mini";
|
|
2
5
|
const JSON_VALUE_SCHEMA = z.json();
|
|
6
|
+
const TEXT_APPLICATION_MEDIA_TYPES = new Set([
|
|
7
|
+
"application/javascript",
|
|
8
|
+
"application/json-seq",
|
|
9
|
+
"application/ndjson",
|
|
10
|
+
"application/sql",
|
|
11
|
+
"application/toml",
|
|
12
|
+
"application/x-ndjson",
|
|
13
|
+
"application/x-yaml",
|
|
14
|
+
"application/yaml",
|
|
15
|
+
]);
|
|
16
|
+
const XML_DECLARATION_ENCODING = /^<\?xml\s+[^>]*?\bencoding\s*=\s*(["'])([a-z][a-z\d._-]*)\1/i;
|
|
17
|
+
/** An HTTP request body that can't be decoded as its declared media type. */
|
|
18
|
+
export class HttpRequestBodyDecodeError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a request error with its public HTTP status.
|
|
22
|
+
*
|
|
23
|
+
* @param message - Safe response message.
|
|
24
|
+
* @param status - HTTP status returned by ingress.
|
|
25
|
+
* @param options - Optional underlying error details.
|
|
26
|
+
*/
|
|
27
|
+
constructor(message, status, options) {
|
|
28
|
+
super(message, options);
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.name = "HttpRequestBodyDecodeError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
3
33
|
/**
|
|
4
34
|
* Decodes HTTP request bytes according to the declared media type.
|
|
5
35
|
*
|
|
6
36
|
* @param rawBody - Exact request body bytes.
|
|
7
37
|
* @param contentType - Declared Content-Type header, when present.
|
|
38
|
+
* @throws {HttpRequestBodyDecodeError} When a declared body can't be decoded.
|
|
8
39
|
*/
|
|
9
40
|
export function decodeHttpRequestBody(rawBody, contentType) {
|
|
10
41
|
if (rawBody.byteLength === 0) {
|
|
11
42
|
return { body: null, bodyType: "empty" };
|
|
12
43
|
}
|
|
13
|
-
const
|
|
44
|
+
const parsedContentType = contentType
|
|
45
|
+
? parseContentType(contentType)
|
|
46
|
+
: undefined;
|
|
47
|
+
const mediaType = parsedContentType?.type.toLowerCase() ?? "";
|
|
48
|
+
if (mediaType === "multipart/form-data") {
|
|
49
|
+
return {
|
|
50
|
+
body: decodeMultipartForm(rawBody, parsedContentType?.parameters.boundary),
|
|
51
|
+
bodyType: "form",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
14
54
|
if (mediaType === "application/x-www-form-urlencoded") {
|
|
15
55
|
const values = new Map();
|
|
16
56
|
for (const [key, value] of new URLSearchParams(decodeText(rawBody))) {
|
|
@@ -29,24 +69,183 @@ export function decodeHttpRequestBody(rawBody, contentType) {
|
|
|
29
69
|
};
|
|
30
70
|
}
|
|
31
71
|
if (mediaType === "application/json" || mediaType.endsWith("+json")) {
|
|
72
|
+
try {
|
|
73
|
+
return {
|
|
74
|
+
body: JSON_VALUE_SCHEMA.parse(JSON.parse(decodeText(rawBody))),
|
|
75
|
+
bodyType: "json",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
throw new HttpRequestBodyDecodeError("Request body is not valid JSON.", 400, { cause });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (isXmlMediaType(mediaType)) {
|
|
32
83
|
return {
|
|
33
|
-
body:
|
|
34
|
-
bodyType: "
|
|
84
|
+
body: decodeText(rawBody, getXmlCharset(rawBody, parsedContentType?.parameters.charset)),
|
|
85
|
+
bodyType: "text",
|
|
35
86
|
};
|
|
36
87
|
}
|
|
37
88
|
if (mediaType.startsWith("text/") ||
|
|
38
|
-
mediaType
|
|
39
|
-
mediaType
|
|
40
|
-
|
|
41
|
-
|
|
89
|
+
TEXT_APPLICATION_MEDIA_TYPES.has(mediaType) ||
|
|
90
|
+
mediaType.endsWith("+yaml")) {
|
|
91
|
+
return {
|
|
92
|
+
body: decodeText(rawBody, parsedContentType?.parameters.charset),
|
|
93
|
+
bodyType: "text",
|
|
94
|
+
};
|
|
42
95
|
}
|
|
43
96
|
return { body: rawBody, bodyType: "bytes" };
|
|
44
97
|
}
|
|
45
98
|
/**
|
|
46
|
-
* Decodes
|
|
99
|
+
* Decodes a multipart form into fields and files grouped by field name.
|
|
47
100
|
*
|
|
48
|
-
* @param rawBody - Exact request
|
|
101
|
+
* @param rawBody - Exact multipart request bytes.
|
|
102
|
+
* @param boundary - Boundary declared by the multipart Content-Type.
|
|
103
|
+
* @throws {HttpRequestBodyDecodeError} When the form is malformed or exceeds
|
|
104
|
+
* parser limits.
|
|
105
|
+
*/
|
|
106
|
+
function decodeMultipartForm(rawBody, boundary) {
|
|
107
|
+
if (!boundary) {
|
|
108
|
+
throw new HttpRequestBodyDecodeError("Multipart form Content-Type is missing its boundary.", 400);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const values = new Map();
|
|
112
|
+
for (const part of parseMultipart(rawBody, {
|
|
113
|
+
boundary,
|
|
114
|
+
// Ingress already buffers the complete request. Preserve its existing
|
|
115
|
+
// size behavior instead of imposing the parser's lower file defaults.
|
|
116
|
+
maxFileSize: rawBody.byteLength,
|
|
117
|
+
maxTotalSize: rawBody.byteLength,
|
|
118
|
+
})) {
|
|
119
|
+
if (!part.name) {
|
|
120
|
+
throw new HttpRequestBodyDecodeError("Multipart form part is missing its field name.", 400);
|
|
121
|
+
}
|
|
122
|
+
const partContentType = part.headers["content-type"]
|
|
123
|
+
? parseContentType(part.headers["content-type"])
|
|
124
|
+
: undefined;
|
|
125
|
+
const value = part.isFile
|
|
126
|
+
? new File([part.arrayBuffer], part.filename ?? "", {
|
|
127
|
+
lastModified: 0,
|
|
128
|
+
type: part.mediaType ?? "application/octet-stream",
|
|
129
|
+
})
|
|
130
|
+
: decodeText(part.bytes, partContentType?.parameters.charset);
|
|
131
|
+
const existing = values.get(part.name);
|
|
132
|
+
if (existing)
|
|
133
|
+
existing.push(value);
|
|
134
|
+
else
|
|
135
|
+
values.set(part.name, [value]);
|
|
136
|
+
}
|
|
137
|
+
return Object.fromEntries([...values].map(([key, entries]) => [
|
|
138
|
+
key,
|
|
139
|
+
entries.length === 1 ? entries[0] : entries,
|
|
140
|
+
]));
|
|
141
|
+
}
|
|
142
|
+
catch (cause) {
|
|
143
|
+
if (cause instanceof HttpRequestBodyDecodeError)
|
|
144
|
+
throw cause;
|
|
145
|
+
if (cause instanceof MaxFileSizeExceededError ||
|
|
146
|
+
cause instanceof MaxHeaderSizeExceededError ||
|
|
147
|
+
cause instanceof MaxPartsExceededError ||
|
|
148
|
+
cause instanceof MaxTotalSizeExceededError) {
|
|
149
|
+
throw new HttpRequestBodyDecodeError("Multipart form exceeds the supported size or part count.", 413, { cause });
|
|
150
|
+
}
|
|
151
|
+
if (cause instanceof MultipartParseError || cause instanceof TypeError) {
|
|
152
|
+
throw new HttpRequestBodyDecodeError("Request body is not valid multipart form data.", 400, { cause });
|
|
153
|
+
}
|
|
154
|
+
throw cause;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Decodes bytes with the declared character encoding, defaulting to UTF-8.
|
|
159
|
+
*
|
|
160
|
+
* @param rawBody - Text body bytes.
|
|
161
|
+
* @param charset - WHATWG character encoding label.
|
|
162
|
+
* @throws {HttpRequestBodyDecodeError} When the encoding is unsupported or the
|
|
163
|
+
* bytes are invalid for it.
|
|
164
|
+
*/
|
|
165
|
+
function decodeText(rawBody, charset = "utf-8") {
|
|
166
|
+
try {
|
|
167
|
+
return new TextDecoder(charset, { fatal: true }).decode(rawBody);
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
if (cause instanceof RangeError) {
|
|
171
|
+
throw new HttpRequestBodyDecodeError(`Request body uses unsupported character encoding ${charset}.`, 415, { cause });
|
|
172
|
+
}
|
|
173
|
+
throw new HttpRequestBodyDecodeError(`Request body is not valid ${charset} text.`, 400, { cause });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Selects XML encoding using BOM, MIME charset, then the XML declaration.
|
|
178
|
+
*
|
|
179
|
+
* @param rawBody - XML body bytes.
|
|
180
|
+
* @param declaredCharset - MIME charset parameter, when present.
|
|
181
|
+
*/
|
|
182
|
+
function getXmlCharset(rawBody, declaredCharset) {
|
|
183
|
+
const bomCharset = detectXmlBom(rawBody);
|
|
184
|
+
if (bomCharset)
|
|
185
|
+
return bomCharset;
|
|
186
|
+
if (declaredCharset)
|
|
187
|
+
return declaredCharset;
|
|
188
|
+
const inferredCharset = detectXmlBytePattern(rawBody) ?? "utf-8";
|
|
189
|
+
try {
|
|
190
|
+
return (XML_DECLARATION_ENCODING.exec(new TextDecoder(inferredCharset).decode(rawBody.subarray(0, 1024)))?.[2] ?? inferredCharset);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return inferredCharset;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Detects a Unicode byte order mark.
|
|
198
|
+
*
|
|
199
|
+
* @param rawBody - XML body bytes.
|
|
200
|
+
*/
|
|
201
|
+
function detectXmlBom(rawBody) {
|
|
202
|
+
if (startsWithBytes(rawBody, [0x00, 0x00, 0xfe, 0xff]))
|
|
203
|
+
return "utf-32be";
|
|
204
|
+
if (startsWithBytes(rawBody, [0xff, 0xfe, 0x00, 0x00]))
|
|
205
|
+
return "utf-32le";
|
|
206
|
+
if (startsWithBytes(rawBody, [0xef, 0xbb, 0xbf]))
|
|
207
|
+
return "utf-8";
|
|
208
|
+
if (startsWithBytes(rawBody, [0xfe, 0xff]))
|
|
209
|
+
return "utf-16be";
|
|
210
|
+
if (startsWithBytes(rawBody, [0xff, 0xfe]))
|
|
211
|
+
return "utf-16le";
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Infers XML encodings whose opening declaration has a distinctive byte form.
|
|
215
|
+
*
|
|
216
|
+
* @param rawBody - XML body bytes.
|
|
217
|
+
*/
|
|
218
|
+
function detectXmlBytePattern(rawBody) {
|
|
219
|
+
if (startsWithBytes(rawBody, [0x00, 0x00, 0x00, 0x3c]))
|
|
220
|
+
return "utf-32be";
|
|
221
|
+
if (startsWithBytes(rawBody, [0x3c, 0x00, 0x00, 0x00]))
|
|
222
|
+
return "utf-32le";
|
|
223
|
+
if (startsWithBytes(rawBody, [0x00, 0x3c, 0x00, 0x3f]))
|
|
224
|
+
return "utf-16be";
|
|
225
|
+
if (startsWithBytes(rawBody, [0x3c, 0x00, 0x3f, 0x00]))
|
|
226
|
+
return "utf-16le";
|
|
227
|
+
if (startsWithBytes(rawBody, [0x4c, 0x6f, 0xa7, 0x94]))
|
|
228
|
+
return "ibm037";
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Checks an exact byte prefix.
|
|
232
|
+
*
|
|
233
|
+
* @param rawBody - Complete body bytes.
|
|
234
|
+
* @param prefix - Bytes expected at the start.
|
|
235
|
+
*/
|
|
236
|
+
function startsWithBytes(rawBody, prefix) {
|
|
237
|
+
return prefix.every((byte, index) => rawBody[index] === byte);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Recognizes XML media types that require XML encoding precedence.
|
|
241
|
+
*
|
|
242
|
+
* @param mediaType - Normalized media type.
|
|
49
243
|
*/
|
|
50
|
-
function
|
|
51
|
-
return
|
|
244
|
+
function isXmlMediaType(mediaType) {
|
|
245
|
+
return (mediaType === "application/xml" ||
|
|
246
|
+
mediaType === "application/xml-dtd" ||
|
|
247
|
+
mediaType === "application/xml-external-parsed-entity" ||
|
|
248
|
+
mediaType === "text/xml" ||
|
|
249
|
+
mediaType === "text/xml-external-parsed-entity" ||
|
|
250
|
+
mediaType.endsWith("+xml"));
|
|
52
251
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
export { isSensitivityMask, projectSensitivityMask, redactSensitiveValue, type SensitivityMask, type StructuralSensitivityMask, } from "./sensitivity.js";
|
|
2
3
|
export type { ObjectProducingSchema, ProducingSchema } from "./schema.js";
|
|
3
4
|
/**
|
|
4
5
|
* Values accepted by the codec encoder, including recursively nested containers
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { isPlainObject } from "./lib/utils.js";
|
|
3
3
|
import { packr } from "./packr.js";
|
|
4
4
|
import { bufferAsyncValues } from "./walk.js";
|
|
5
|
+
export { isSensitivityMask, projectSensitivityMask, redactSensitiveValue, } from "./sensitivity.js";
|
|
5
6
|
/**
|
|
6
7
|
* Asserts that a value belongs to the codec's supported input surface.
|
|
7
8
|
*
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Encodable } from "./index.js";
|
|
2
|
+
/** Runtime representation of a whole-value or structural sensitivity policy. */
|
|
3
|
+
export type SensitivityMask = true | readonly SensitivityMask[] | {
|
|
4
|
+
readonly [key: string]: SensitivityMask | undefined;
|
|
5
|
+
};
|
|
6
|
+
/** Codec object types whose internal properties are not maskable structure. */
|
|
7
|
+
type AtomicEncodableObject = ArrayBuffer | ArrayBufferView | Blob | Date | File | Headers | Map<unknown, unknown> | RegExp | Request | Response | Set<unknown> | URL | URLSearchParams;
|
|
8
|
+
/**
|
|
9
|
+
* Mirrors an output value with `true` at each subtree that must not be exposed.
|
|
10
|
+
*
|
|
11
|
+
* A one-element array mask applies to every array element. Non-structural codec
|
|
12
|
+
* values can only be marked wholly sensitive.
|
|
13
|
+
*/
|
|
14
|
+
export type StructuralSensitivityMask<T> = true | (T extends unknown ? NonNullable<T> extends AtomicEncodableObject ? never : NonNullable<T> extends readonly (infer TItem)[] ? readonly [StructuralSensitivityMask<TItem>] : NonNullable<T> extends object ? {
|
|
15
|
+
readonly [TKey in keyof NonNullable<T>]?: StructuralSensitivityMask<NonNullable<T>[TKey]>;
|
|
16
|
+
} : never : never);
|
|
17
|
+
/**
|
|
18
|
+
* Returns whether a value is a valid runtime sensitivity mask.
|
|
19
|
+
*
|
|
20
|
+
* @param value - Candidate mask.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isSensitivityMask(value: unknown): value is SensitivityMask;
|
|
23
|
+
/**
|
|
24
|
+
* Replaces structurally sensitive subtrees with `undefined` for persistence in
|
|
25
|
+
* author-visible execution data.
|
|
26
|
+
*
|
|
27
|
+
* @param value - Validated codec value being projected.
|
|
28
|
+
* @param mask - Structural policy declared by the value's producer.
|
|
29
|
+
* @throws {TypeError} When the mask does not match the value structure.
|
|
30
|
+
*/
|
|
31
|
+
export declare function redactSensitiveValue<T extends Encodable>(value: T, mask: SensitivityMask): T | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Projects a structural mask through one property access.
|
|
34
|
+
*
|
|
35
|
+
* @param mask - Parent sensitivity mask.
|
|
36
|
+
* @param property - Accessed property name or array index.
|
|
37
|
+
*/
|
|
38
|
+
export declare function projectSensitivityMask(mask: SensitivityMask | undefined, property: string): SensitivityMask | undefined;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isPlainObject } from "./lib/utils.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns whether a value is a valid runtime sensitivity mask.
|
|
4
|
+
*
|
|
5
|
+
* @param value - Candidate mask.
|
|
6
|
+
*/
|
|
7
|
+
export function isSensitivityMask(value) {
|
|
8
|
+
if (value === true)
|
|
9
|
+
return true;
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.length === 1 && isSensitivityMask(value[0]);
|
|
12
|
+
}
|
|
13
|
+
return (typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
isPlainObject(value) &&
|
|
16
|
+
Object.keys(value).length > 0 &&
|
|
17
|
+
Object.values(value).every(isSensitivityMask));
|
|
18
|
+
}
|
|
19
|
+
export function redactSensitiveValue(value, mask) {
|
|
20
|
+
if (mask === true)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (isSensitivityArray(mask)) {
|
|
23
|
+
if (!Array.isArray(value)) {
|
|
24
|
+
throw new TypeError("Array sensitivity masks require array values.");
|
|
25
|
+
}
|
|
26
|
+
return value.map((item) => redactSensitiveValue(item, mask[0]));
|
|
27
|
+
}
|
|
28
|
+
if (typeof value !== "object" || value === null || !isPlainObject(value)) {
|
|
29
|
+
throw new TypeError("Object sensitivity masks require plain-object values.");
|
|
30
|
+
}
|
|
31
|
+
if (Object.keys(mask).some((key) => !Object.hasOwn(value, key))) {
|
|
32
|
+
throw new TypeError("Object sensitivity masks may only name properties present in the value.");
|
|
33
|
+
}
|
|
34
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
35
|
+
key,
|
|
36
|
+
mask[key] ? redactSensitiveValue(item, mask[key]) : item,
|
|
37
|
+
]));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Projects a structural mask through one property access.
|
|
41
|
+
*
|
|
42
|
+
* @param mask - Parent sensitivity mask.
|
|
43
|
+
* @param property - Accessed property name or array index.
|
|
44
|
+
*/
|
|
45
|
+
export function projectSensitivityMask(mask, property) {
|
|
46
|
+
if (mask === true)
|
|
47
|
+
return true;
|
|
48
|
+
if (!mask)
|
|
49
|
+
return;
|
|
50
|
+
if (isSensitivityArray(mask)) {
|
|
51
|
+
const index = Number(property);
|
|
52
|
+
if (!Number.isInteger(index) || index < 0)
|
|
53
|
+
return;
|
|
54
|
+
return mask[0];
|
|
55
|
+
}
|
|
56
|
+
return mask[property];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Narrows the readonly array branch that `Array.isArray` cannot express.
|
|
60
|
+
*
|
|
61
|
+
* @param mask - Runtime sensitivity mask.
|
|
62
|
+
*/
|
|
63
|
+
function isSensitivityArray(mask) {
|
|
64
|
+
return Array.isArray(mask);
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automate.ax/codec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.1",
|
|
4
4
|
"description": "Msgpack-based encoding helpers for Automate.ax runtime data.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"module": "./dist/index.js",
|
|
64
64
|
"types": "./dist/index.d.ts",
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@internal/config": "0.
|
|
66
|
+
"@internal/config": "0.2.0",
|
|
67
67
|
"@types/bun": "latest",
|
|
68
68
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
69
69
|
"@zachsents/oxlint-config": "^0.4.1",
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"zshy": "^0.7.2"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
+
"@remix-run/multipart-parser": "^0.16.4",
|
|
77
78
|
"@standard-schema/spec": "^1.1.0",
|
|
79
|
+
"content-type": "^3.0.0",
|
|
78
80
|
"msgpackr": "^1.11.9",
|
|
79
81
|
"zod": "^4.3.6"
|
|
80
82
|
}
|
package/src/http.ts
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MaxFileSizeExceededError,
|
|
3
|
+
MaxHeaderSizeExceededError,
|
|
4
|
+
MaxPartsExceededError,
|
|
5
|
+
MaxTotalSizeExceededError,
|
|
6
|
+
MultipartParseError,
|
|
7
|
+
parseMultipart,
|
|
8
|
+
} from "@remix-run/multipart-parser"
|
|
9
|
+
import { parse as parseContentType } from "content-type"
|
|
10
|
+
import { TextDecoder } from "node:util"
|
|
1
11
|
import * as z from "zod/mini"
|
|
2
12
|
|
|
3
13
|
const JSON_VALUE_SCHEMA = z.json()
|
|
14
|
+
const TEXT_APPLICATION_MEDIA_TYPES = new Set([
|
|
15
|
+
"application/javascript",
|
|
16
|
+
"application/json-seq",
|
|
17
|
+
"application/ndjson",
|
|
18
|
+
"application/sql",
|
|
19
|
+
"application/toml",
|
|
20
|
+
"application/x-ndjson",
|
|
21
|
+
"application/x-yaml",
|
|
22
|
+
"application/yaml",
|
|
23
|
+
])
|
|
24
|
+
const XML_DECLARATION_ENCODING =
|
|
25
|
+
/^<\?xml\s+[^>]*?\bencoding\s*=\s*(["'])([a-z][a-z\d._-]*)\1/i
|
|
4
26
|
|
|
5
27
|
export type JsonValue = z.infer<typeof JSON_VALUE_SCHEMA>
|
|
6
28
|
|
|
7
|
-
export type
|
|
29
|
+
export type HttpRequestFormValue = File | string
|
|
30
|
+
|
|
31
|
+
export type HttpRequestForm = Record<
|
|
32
|
+
string,
|
|
33
|
+
HttpRequestFormValue | HttpRequestFormValue[]
|
|
34
|
+
>
|
|
35
|
+
|
|
36
|
+
export type HttpRequestBody = HttpRequestForm | JsonValue | Uint8Array
|
|
8
37
|
|
|
9
38
|
export type HttpRequestBodyType = "bytes" | "empty" | "form" | "json" | "text"
|
|
10
39
|
|
|
@@ -13,11 +42,31 @@ export type DecodedHttpRequestBody = {
|
|
|
13
42
|
bodyType: HttpRequestBodyType
|
|
14
43
|
}
|
|
15
44
|
|
|
45
|
+
/** An HTTP request body that can't be decoded as its declared media type. */
|
|
46
|
+
export class HttpRequestBodyDecodeError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* Creates a request error with its public HTTP status.
|
|
49
|
+
*
|
|
50
|
+
* @param message - Safe response message.
|
|
51
|
+
* @param status - HTTP status returned by ingress.
|
|
52
|
+
* @param options - Optional underlying error details.
|
|
53
|
+
*/
|
|
54
|
+
constructor(
|
|
55
|
+
message: string,
|
|
56
|
+
public readonly status: 400 | 413 | 415,
|
|
57
|
+
options?: ErrorOptions,
|
|
58
|
+
) {
|
|
59
|
+
super(message, options)
|
|
60
|
+
this.name = "HttpRequestBodyDecodeError"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
16
64
|
/**
|
|
17
65
|
* Decodes HTTP request bytes according to the declared media type.
|
|
18
66
|
*
|
|
19
67
|
* @param rawBody - Exact request body bytes.
|
|
20
68
|
* @param contentType - Declared Content-Type header, when present.
|
|
69
|
+
* @throws {HttpRequestBodyDecodeError} When a declared body can't be decoded.
|
|
21
70
|
*/
|
|
22
71
|
export function decodeHttpRequestBody(
|
|
23
72
|
rawBody: Uint8Array,
|
|
@@ -27,7 +76,20 @@ export function decodeHttpRequestBody(
|
|
|
27
76
|
return { body: null, bodyType: "empty" }
|
|
28
77
|
}
|
|
29
78
|
|
|
30
|
-
const
|
|
79
|
+
const parsedContentType = contentType
|
|
80
|
+
? parseContentType(contentType)
|
|
81
|
+
: undefined
|
|
82
|
+
const mediaType = parsedContentType?.type.toLowerCase() ?? ""
|
|
83
|
+
if (mediaType === "multipart/form-data") {
|
|
84
|
+
return {
|
|
85
|
+
body: decodeMultipartForm(
|
|
86
|
+
rawBody,
|
|
87
|
+
parsedContentType?.parameters.boundary,
|
|
88
|
+
),
|
|
89
|
+
bodyType: "form",
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
31
93
|
if (mediaType === "application/x-www-form-urlencoded") {
|
|
32
94
|
const values = new Map<string, string[]>()
|
|
33
95
|
for (const [key, value] of new URLSearchParams(decodeText(rawBody))) {
|
|
@@ -47,29 +109,219 @@ export function decodeHttpRequestBody(
|
|
|
47
109
|
}
|
|
48
110
|
|
|
49
111
|
if (mediaType === "application/json" || mediaType.endsWith("+json")) {
|
|
112
|
+
try {
|
|
113
|
+
return {
|
|
114
|
+
body: JSON_VALUE_SCHEMA.parse(JSON.parse(decodeText(rawBody))),
|
|
115
|
+
bodyType: "json",
|
|
116
|
+
}
|
|
117
|
+
} catch (cause) {
|
|
118
|
+
throw new HttpRequestBodyDecodeError(
|
|
119
|
+
"Request body is not valid JSON.",
|
|
120
|
+
400,
|
|
121
|
+
{ cause },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (isXmlMediaType(mediaType)) {
|
|
50
127
|
return {
|
|
51
|
-
body:
|
|
52
|
-
|
|
128
|
+
body: decodeText(
|
|
129
|
+
rawBody,
|
|
130
|
+
getXmlCharset(rawBody, parsedContentType?.parameters.charset),
|
|
131
|
+
),
|
|
132
|
+
bodyType: "text",
|
|
53
133
|
}
|
|
54
134
|
}
|
|
55
135
|
|
|
56
136
|
if (
|
|
57
137
|
mediaType.startsWith("text/") ||
|
|
58
|
-
mediaType
|
|
59
|
-
mediaType
|
|
60
|
-
mediaType.endsWith("+xml")
|
|
138
|
+
TEXT_APPLICATION_MEDIA_TYPES.has(mediaType) ||
|
|
139
|
+
mediaType.endsWith("+yaml")
|
|
61
140
|
) {
|
|
62
|
-
return {
|
|
141
|
+
return {
|
|
142
|
+
body: decodeText(rawBody, parsedContentType?.parameters.charset),
|
|
143
|
+
bodyType: "text",
|
|
144
|
+
}
|
|
63
145
|
}
|
|
64
146
|
|
|
65
147
|
return { body: rawBody, bodyType: "bytes" }
|
|
66
148
|
}
|
|
67
149
|
|
|
68
150
|
/**
|
|
69
|
-
* Decodes
|
|
151
|
+
* Decodes a multipart form into fields and files grouped by field name.
|
|
70
152
|
*
|
|
71
|
-
* @param rawBody - Exact request
|
|
153
|
+
* @param rawBody - Exact multipart request bytes.
|
|
154
|
+
* @param boundary - Boundary declared by the multipart Content-Type.
|
|
155
|
+
* @throws {HttpRequestBodyDecodeError} When the form is malformed or exceeds
|
|
156
|
+
* parser limits.
|
|
157
|
+
*/
|
|
158
|
+
function decodeMultipartForm(rawBody: Uint8Array, boundary?: string) {
|
|
159
|
+
if (!boundary) {
|
|
160
|
+
throw new HttpRequestBodyDecodeError(
|
|
161
|
+
"Multipart form Content-Type is missing its boundary.",
|
|
162
|
+
400,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const values = new Map<string, HttpRequestFormValue[]>()
|
|
168
|
+
for (const part of parseMultipart(rawBody, {
|
|
169
|
+
boundary,
|
|
170
|
+
// Ingress already buffers the complete request. Preserve its existing
|
|
171
|
+
// size behavior instead of imposing the parser's lower file defaults.
|
|
172
|
+
maxFileSize: rawBody.byteLength,
|
|
173
|
+
maxTotalSize: rawBody.byteLength,
|
|
174
|
+
})) {
|
|
175
|
+
if (!part.name) {
|
|
176
|
+
throw new HttpRequestBodyDecodeError(
|
|
177
|
+
"Multipart form part is missing its field name.",
|
|
178
|
+
400,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const partContentType = part.headers["content-type"]
|
|
183
|
+
? parseContentType(part.headers["content-type"])
|
|
184
|
+
: undefined
|
|
185
|
+
const value = part.isFile
|
|
186
|
+
? new File([part.arrayBuffer], part.filename ?? "", {
|
|
187
|
+
lastModified: 0,
|
|
188
|
+
type: part.mediaType ?? "application/octet-stream",
|
|
189
|
+
})
|
|
190
|
+
: decodeText(part.bytes, partContentType?.parameters.charset)
|
|
191
|
+
const existing = values.get(part.name)
|
|
192
|
+
if (existing) existing.push(value)
|
|
193
|
+
else values.set(part.name, [value])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Object.fromEntries(
|
|
197
|
+
[...values].map(([key, entries]) => [
|
|
198
|
+
key,
|
|
199
|
+
entries.length === 1 ? entries[0]! : entries,
|
|
200
|
+
]),
|
|
201
|
+
)
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
if (cause instanceof HttpRequestBodyDecodeError) throw cause
|
|
204
|
+
if (
|
|
205
|
+
cause instanceof MaxFileSizeExceededError ||
|
|
206
|
+
cause instanceof MaxHeaderSizeExceededError ||
|
|
207
|
+
cause instanceof MaxPartsExceededError ||
|
|
208
|
+
cause instanceof MaxTotalSizeExceededError
|
|
209
|
+
) {
|
|
210
|
+
throw new HttpRequestBodyDecodeError(
|
|
211
|
+
"Multipart form exceeds the supported size or part count.",
|
|
212
|
+
413,
|
|
213
|
+
{ cause },
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (cause instanceof MultipartParseError || cause instanceof TypeError) {
|
|
217
|
+
throw new HttpRequestBodyDecodeError(
|
|
218
|
+
"Request body is not valid multipart form data.",
|
|
219
|
+
400,
|
|
220
|
+
{ cause },
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
throw cause
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Decodes bytes with the declared character encoding, defaulting to UTF-8.
|
|
229
|
+
*
|
|
230
|
+
* @param rawBody - Text body bytes.
|
|
231
|
+
* @param charset - WHATWG character encoding label.
|
|
232
|
+
* @throws {HttpRequestBodyDecodeError} When the encoding is unsupported or the
|
|
233
|
+
* bytes are invalid for it.
|
|
234
|
+
*/
|
|
235
|
+
function decodeText(rawBody: Uint8Array, charset = "utf-8") {
|
|
236
|
+
try {
|
|
237
|
+
return new TextDecoder(charset, { fatal: true }).decode(rawBody)
|
|
238
|
+
} catch (cause) {
|
|
239
|
+
if (cause instanceof RangeError) {
|
|
240
|
+
throw new HttpRequestBodyDecodeError(
|
|
241
|
+
`Request body uses unsupported character encoding ${charset}.`,
|
|
242
|
+
415,
|
|
243
|
+
{ cause },
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
throw new HttpRequestBodyDecodeError(
|
|
247
|
+
`Request body is not valid ${charset} text.`,
|
|
248
|
+
400,
|
|
249
|
+
{ cause },
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Selects XML encoding using BOM, MIME charset, then the XML declaration.
|
|
256
|
+
*
|
|
257
|
+
* @param rawBody - XML body bytes.
|
|
258
|
+
* @param declaredCharset - MIME charset parameter, when present.
|
|
72
259
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
260
|
+
function getXmlCharset(rawBody: Uint8Array, declaredCharset?: string) {
|
|
261
|
+
const bomCharset = detectXmlBom(rawBody)
|
|
262
|
+
if (bomCharset) return bomCharset
|
|
263
|
+
if (declaredCharset) return declaredCharset
|
|
264
|
+
|
|
265
|
+
const inferredCharset = detectXmlBytePattern(rawBody) ?? "utf-8"
|
|
266
|
+
try {
|
|
267
|
+
return (
|
|
268
|
+
XML_DECLARATION_ENCODING.exec(
|
|
269
|
+
new TextDecoder(inferredCharset).decode(rawBody.subarray(0, 1024)),
|
|
270
|
+
)?.[2] ?? inferredCharset
|
|
271
|
+
)
|
|
272
|
+
} catch {
|
|
273
|
+
return inferredCharset
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Detects a Unicode byte order mark.
|
|
279
|
+
*
|
|
280
|
+
* @param rawBody - XML body bytes.
|
|
281
|
+
*/
|
|
282
|
+
function detectXmlBom(rawBody: Uint8Array) {
|
|
283
|
+
if (startsWithBytes(rawBody, [0x00, 0x00, 0xfe, 0xff])) return "utf-32be"
|
|
284
|
+
if (startsWithBytes(rawBody, [0xff, 0xfe, 0x00, 0x00])) return "utf-32le"
|
|
285
|
+
if (startsWithBytes(rawBody, [0xef, 0xbb, 0xbf])) return "utf-8"
|
|
286
|
+
if (startsWithBytes(rawBody, [0xfe, 0xff])) return "utf-16be"
|
|
287
|
+
if (startsWithBytes(rawBody, [0xff, 0xfe])) return "utf-16le"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Infers XML encodings whose opening declaration has a distinctive byte form.
|
|
292
|
+
*
|
|
293
|
+
* @param rawBody - XML body bytes.
|
|
294
|
+
*/
|
|
295
|
+
function detectXmlBytePattern(rawBody: Uint8Array) {
|
|
296
|
+
if (startsWithBytes(rawBody, [0x00, 0x00, 0x00, 0x3c])) return "utf-32be"
|
|
297
|
+
if (startsWithBytes(rawBody, [0x3c, 0x00, 0x00, 0x00])) return "utf-32le"
|
|
298
|
+
if (startsWithBytes(rawBody, [0x00, 0x3c, 0x00, 0x3f])) return "utf-16be"
|
|
299
|
+
if (startsWithBytes(rawBody, [0x3c, 0x00, 0x3f, 0x00])) return "utf-16le"
|
|
300
|
+
if (startsWithBytes(rawBody, [0x4c, 0x6f, 0xa7, 0x94])) return "ibm037"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Checks an exact byte prefix.
|
|
305
|
+
*
|
|
306
|
+
* @param rawBody - Complete body bytes.
|
|
307
|
+
* @param prefix - Bytes expected at the start.
|
|
308
|
+
*/
|
|
309
|
+
function startsWithBytes(rawBody: Uint8Array, prefix: number[]) {
|
|
310
|
+
return prefix.every((byte, index) => rawBody[index] === byte)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Recognizes XML media types that require XML encoding precedence.
|
|
315
|
+
*
|
|
316
|
+
* @param mediaType - Normalized media type.
|
|
317
|
+
*/
|
|
318
|
+
function isXmlMediaType(mediaType: string) {
|
|
319
|
+
return (
|
|
320
|
+
mediaType === "application/xml" ||
|
|
321
|
+
mediaType === "application/xml-dtd" ||
|
|
322
|
+
mediaType === "application/xml-external-parsed-entity" ||
|
|
323
|
+
mediaType === "text/xml" ||
|
|
324
|
+
mediaType === "text/xml-external-parsed-entity" ||
|
|
325
|
+
mediaType.endsWith("+xml")
|
|
326
|
+
)
|
|
75
327
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { isPlainObject } from "./lib/utils"
|
|
|
3
3
|
import { packr } from "./packr"
|
|
4
4
|
import { bufferAsyncValues } from "./walk"
|
|
5
5
|
|
|
6
|
+
export {
|
|
7
|
+
isSensitivityMask,
|
|
8
|
+
projectSensitivityMask,
|
|
9
|
+
redactSensitiveValue,
|
|
10
|
+
type SensitivityMask,
|
|
11
|
+
type StructuralSensitivityMask,
|
|
12
|
+
} from "./sensitivity"
|
|
13
|
+
|
|
6
14
|
export type { ObjectProducingSchema, ProducingSchema } from "./schema"
|
|
7
15
|
|
|
8
16
|
/**
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { isPlainObject } from "./lib/utils"
|
|
2
|
+
import type { Encodable } from "./index"
|
|
3
|
+
|
|
4
|
+
/** Runtime representation of a whole-value or structural sensitivity policy. */
|
|
5
|
+
export type SensitivityMask =
|
|
6
|
+
| true
|
|
7
|
+
| readonly SensitivityMask[]
|
|
8
|
+
| { readonly [key: string]: SensitivityMask | undefined }
|
|
9
|
+
|
|
10
|
+
/** Codec object types whose internal properties are not maskable structure. */
|
|
11
|
+
type AtomicEncodableObject =
|
|
12
|
+
| ArrayBuffer
|
|
13
|
+
| ArrayBufferView
|
|
14
|
+
| Blob
|
|
15
|
+
| Date
|
|
16
|
+
| File
|
|
17
|
+
| Headers
|
|
18
|
+
| Map<unknown, unknown>
|
|
19
|
+
| RegExp
|
|
20
|
+
| Request
|
|
21
|
+
| Response
|
|
22
|
+
| Set<unknown>
|
|
23
|
+
| URL
|
|
24
|
+
| URLSearchParams
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mirrors an output value with `true` at each subtree that must not be exposed.
|
|
28
|
+
*
|
|
29
|
+
* A one-element array mask applies to every array element. Non-structural codec
|
|
30
|
+
* values can only be marked wholly sensitive.
|
|
31
|
+
*/
|
|
32
|
+
export type StructuralSensitivityMask<T> =
|
|
33
|
+
| true
|
|
34
|
+
| (T extends unknown
|
|
35
|
+
? NonNullable<T> extends AtomicEncodableObject
|
|
36
|
+
? never
|
|
37
|
+
: NonNullable<T> extends readonly (infer TItem)[]
|
|
38
|
+
? readonly [StructuralSensitivityMask<TItem>]
|
|
39
|
+
: NonNullable<T> extends object
|
|
40
|
+
? {
|
|
41
|
+
readonly [TKey in keyof NonNullable<T>]?: StructuralSensitivityMask<
|
|
42
|
+
NonNullable<T>[TKey]
|
|
43
|
+
>
|
|
44
|
+
}
|
|
45
|
+
: never
|
|
46
|
+
: never)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns whether a value is a valid runtime sensitivity mask.
|
|
50
|
+
*
|
|
51
|
+
* @param value - Candidate mask.
|
|
52
|
+
*/
|
|
53
|
+
export function isSensitivityMask(value: unknown): value is SensitivityMask {
|
|
54
|
+
if (value === true) return true
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.length === 1 && isSensitivityMask(value[0])
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
typeof value === "object" &&
|
|
60
|
+
value !== null &&
|
|
61
|
+
isPlainObject(value) &&
|
|
62
|
+
Object.keys(value).length > 0 &&
|
|
63
|
+
Object.values(value).every(isSensitivityMask)
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replaces structurally sensitive subtrees with `undefined` for persistence in
|
|
69
|
+
* author-visible execution data.
|
|
70
|
+
*
|
|
71
|
+
* @param value - Validated codec value being projected.
|
|
72
|
+
* @param mask - Structural policy declared by the value's producer.
|
|
73
|
+
* @throws {TypeError} When the mask does not match the value structure.
|
|
74
|
+
*/
|
|
75
|
+
export function redactSensitiveValue<T extends Encodable>(
|
|
76
|
+
value: T,
|
|
77
|
+
mask: SensitivityMask,
|
|
78
|
+
): T | undefined
|
|
79
|
+
export function redactSensitiveValue(
|
|
80
|
+
value: Encodable,
|
|
81
|
+
mask: SensitivityMask,
|
|
82
|
+
): Encodable {
|
|
83
|
+
if (mask === true) return undefined
|
|
84
|
+
|
|
85
|
+
if (isSensitivityArray(mask)) {
|
|
86
|
+
if (!Array.isArray(value)) {
|
|
87
|
+
throw new TypeError("Array sensitivity masks require array values.")
|
|
88
|
+
}
|
|
89
|
+
return value.map((item) => redactSensitiveValue(item, mask[0]!))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof value !== "object" || value === null || !isPlainObject(value)) {
|
|
93
|
+
throw new TypeError("Object sensitivity masks require plain-object values.")
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(mask).some((key) => !Object.hasOwn(value, key))) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
"Object sensitivity masks may only name properties present in the value.",
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return Object.fromEntries(
|
|
101
|
+
Object.entries(value).map(([key, item]) => [
|
|
102
|
+
key,
|
|
103
|
+
mask[key] ? redactSensitiveValue(item, mask[key]) : item,
|
|
104
|
+
]),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Projects a structural mask through one property access.
|
|
110
|
+
*
|
|
111
|
+
* @param mask - Parent sensitivity mask.
|
|
112
|
+
* @param property - Accessed property name or array index.
|
|
113
|
+
*/
|
|
114
|
+
export function projectSensitivityMask(
|
|
115
|
+
mask: SensitivityMask | undefined,
|
|
116
|
+
property: string,
|
|
117
|
+
): SensitivityMask | undefined {
|
|
118
|
+
if (mask === true) return true
|
|
119
|
+
if (!mask) return
|
|
120
|
+
if (isSensitivityArray(mask)) {
|
|
121
|
+
const index = Number(property)
|
|
122
|
+
if (!Number.isInteger(index) || index < 0) return
|
|
123
|
+
return mask[0]!
|
|
124
|
+
}
|
|
125
|
+
return mask[property]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Narrows the readonly array branch that `Array.isArray` cannot express.
|
|
130
|
+
*
|
|
131
|
+
* @param mask - Runtime sensitivity mask.
|
|
132
|
+
*/
|
|
133
|
+
function isSensitivityArray(
|
|
134
|
+
mask: SensitivityMask,
|
|
135
|
+
): mask is readonly SensitivityMask[] {
|
|
136
|
+
return Array.isArray(mask)
|
|
137
|
+
}
|