@automate.ax/codec 0.83.3 → 0.85.0

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 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 HttpRequestBody = JsonValue | Uint8Array;
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 mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
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: JSON_VALUE_SCHEMA.parse(JSON.parse(decodeText(rawBody))),
34
- bodyType: "json",
84
+ body: decodeText(rawBody, getXmlCharset(rawBody, parsedContentType?.parameters.charset)),
85
+ bodyType: "text",
35
86
  };
36
87
  }
37
88
  if (mediaType.startsWith("text/") ||
38
- mediaType === "application/javascript" ||
39
- mediaType === "application/xml" ||
40
- mediaType.endsWith("+xml")) {
41
- return { body: decodeText(rawBody), bodyType: "text" };
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 request bytes as UTF-8, replacing invalid byte sequences.
99
+ * Decodes a multipart form into fields and files grouped by field name.
47
100
  *
48
- * @param rawBody - Exact request body bytes.
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 decodeText(rawBody) {
51
- return new TextDecoder().decode(rawBody);
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.83.3",
3
+ "version": "0.85.0",
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.1.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 HttpRequestBody = JsonValue | Uint8Array
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 mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""
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: JSON_VALUE_SCHEMA.parse(JSON.parse(decodeText(rawBody))),
52
- bodyType: "json",
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 === "application/javascript" ||
59
- mediaType === "application/xml" ||
60
- mediaType.endsWith("+xml")
138
+ TEXT_APPLICATION_MEDIA_TYPES.has(mediaType) ||
139
+ mediaType.endsWith("+yaml")
61
140
  ) {
62
- return { body: decodeText(rawBody), bodyType: "text" }
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 request bytes as UTF-8, replacing invalid byte sequences.
151
+ * Decodes a multipart form into fields and files grouped by field name.
70
152
  *
71
- * @param rawBody - Exact request body bytes.
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 decodeText(rawBody: Uint8Array) {
74
- return new TextDecoder().decode(rawBody)
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
+ }