hbsig 0.3.1 → 0.3.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.
Files changed (94) hide show
  1. package/.babelrc-cjs +5 -0
  2. package/.babelrc-esm +5 -0
  3. package/README.md +1 -0
  4. package/dist/package.json +39 -0
  5. package/make.js +36 -0
  6. package/package.json +16 -16
  7. package/src/bin_to_str.js +46 -0
  8. package/src/collect-body-keys.js +436 -0
  9. package/src/commit.js +219 -0
  10. package/src/encode-array-item.js +112 -0
  11. package/src/encode-utils.js +191 -0
  12. package/src/encode.js +1256 -0
  13. package/src/erl_json.js +292 -0
  14. package/src/erl_str.js +1144 -0
  15. package/src/flat.js +250 -0
  16. package/src/http-message-signatures/httpbis.js +438 -0
  17. package/src/http-message-signatures/index.js +4 -0
  18. package/src/http-message-signatures/structured-header.js +105 -0
  19. package/src/httpsig.js +866 -0
  20. package/src/id.js +459 -0
  21. package/src/index.js +13 -0
  22. package/src/nocrypto.js +4 -0
  23. package/src/parser.js +171 -0
  24. package/src/send-utils.js +1132 -0
  25. package/src/send.js +142 -0
  26. package/src/signer-utils.js +375 -0
  27. package/src/signer.js +312 -0
  28. package/src/structured.js +496 -0
  29. package/src/test.js +2 -0
  30. package/src/utils.js +29 -0
  31. package/test/commit.test.js +41 -0
  32. package/test/erl_json.test.js +8 -0
  33. package/test/flat.test.js +27 -0
  34. package/test/httpsig.test.js +31 -0
  35. package/test/id.test.js +114 -0
  36. package/test/lib/all_cases.js +408 -0
  37. package/test/lib/cases.js +408 -0
  38. package/test/lib/erl_json_cases.js +161 -0
  39. package/test/lib/flat_cases.js +189 -0
  40. package/test/lib/gen.js +528 -0
  41. package/test/lib/httpsig_cases.js +313 -0
  42. package/test/lib/structured_cases.js +222 -0
  43. package/test/lib/test-utils.js +399 -0
  44. package/test/signer.test.js +48 -0
  45. package/test/structured.test.js +35 -0
  46. /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
  47. /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
  48. /package/{cjs → dist/cjs}/commit.js +0 -0
  49. /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
  50. /package/{cjs → dist/cjs}/encode-utils.js +0 -0
  51. /package/{cjs → dist/cjs}/encode.js +0 -0
  52. /package/{cjs → dist/cjs}/erl_json.js +0 -0
  53. /package/{cjs → dist/cjs}/erl_str.js +0 -0
  54. /package/{cjs → dist/cjs}/flat.js +0 -0
  55. /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
  56. /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
  57. /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
  58. /package/{cjs → dist/cjs}/httpsig.js +0 -0
  59. /package/{cjs → dist/cjs}/id.js +0 -0
  60. /package/{cjs → dist/cjs}/index.js +0 -0
  61. /package/{cjs → dist/cjs}/nocrypto.js +0 -0
  62. /package/{cjs → dist/cjs}/parser.js +0 -0
  63. /package/{cjs → dist/cjs}/send-utils.js +0 -0
  64. /package/{cjs → dist/cjs}/send.js +0 -0
  65. /package/{cjs → dist/cjs}/signer-utils.js +0 -0
  66. /package/{cjs → dist/cjs}/signer.js +0 -0
  67. /package/{cjs → dist/cjs}/structured.js +0 -0
  68. /package/{cjs → dist/cjs}/test.js +0 -0
  69. /package/{cjs → dist/cjs}/utils.js +0 -0
  70. /package/{esm → dist/esm}/bin_to_str.js +0 -0
  71. /package/{esm → dist/esm}/collect-body-keys.js +0 -0
  72. /package/{esm → dist/esm}/commit.js +0 -0
  73. /package/{esm → dist/esm}/encode-array-item.js +0 -0
  74. /package/{esm → dist/esm}/encode-utils.js +0 -0
  75. /package/{esm → dist/esm}/encode.js +0 -0
  76. /package/{esm → dist/esm}/erl_json.js +0 -0
  77. /package/{esm → dist/esm}/erl_str.js +0 -0
  78. /package/{esm → dist/esm}/flat.js +0 -0
  79. /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
  80. /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
  81. /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
  82. /package/{esm → dist/esm}/httpsig.js +0 -0
  83. /package/{esm → dist/esm}/id.js +0 -0
  84. /package/{esm → dist/esm}/index.js +0 -0
  85. /package/{esm → dist/esm}/nocrypto.js +0 -0
  86. /package/{esm → dist/esm}/package.json +0 -0
  87. /package/{esm → dist/esm}/parser.js +0 -0
  88. /package/{esm → dist/esm}/send-utils.js +0 -0
  89. /package/{esm → dist/esm}/send.js +0 -0
  90. /package/{esm → dist/esm}/signer-utils.js +0 -0
  91. /package/{esm → dist/esm}/signer.js +0 -0
  92. /package/{esm → dist/esm}/structured.js +0 -0
  93. /package/{esm → dist/esm}/test.js +0 -0
  94. /package/{esm → dist/esm}/utils.js +0 -0
package/src/httpsig.js ADDED
@@ -0,0 +1,866 @@
1
+ // httpsig.js - JavaScript implementation of HTTP Signature codec
2
+
3
+ import { hash } from "fast-sha256"
4
+ import { flat_from, flat_to } from "./flat.js"
5
+ import { structured_from as structuredFrom, structured_to as structuredTo } from "./structured.js"
6
+
7
+ const CRLF = "\r\n"
8
+ const DOUBLE_CRLF = CRLF + CRLF
9
+ const MAX_HEADER_LENGTH = 4096
10
+
11
+ // Helper to convert string to Uint8Array
12
+ function stringToBytes(str, encoding = "utf8") {
13
+ if (encoding === "binary") {
14
+ const bytes = new Uint8Array(str.length)
15
+ for (let i = 0; i < str.length; i++) {
16
+ bytes[i] = str.charCodeAt(i) & 0xff
17
+ }
18
+ return bytes
19
+ }
20
+ return new TextEncoder().encode(str)
21
+ }
22
+
23
+ // Helper to convert bytes to base64url
24
+ function bytesToBase64url(bytes) {
25
+ const chars =
26
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
27
+ let result = ""
28
+
29
+ for (let i = 0; i < bytes.length; i += 3) {
30
+ const a = bytes[i]
31
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0
32
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0
33
+
34
+ const combined = (a << 16) | (b << 8) | c
35
+
36
+ result += chars[(combined >> 18) & 0x3f]
37
+ result += chars[(combined >> 12) & 0x3f]
38
+ if (i + 1 < bytes.length) result += chars[(combined >> 6) & 0x3f]
39
+ if (i + 2 < bytes.length) result += chars[combined & 0x3f]
40
+ }
41
+
42
+ return result
43
+ }
44
+
45
+ // Helper to convert bytes to base64
46
+ function bytesToBase64(bytes) {
47
+ const chars =
48
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
49
+ let result = ""
50
+
51
+ for (let i = 0; i < bytes.length; i += 3) {
52
+ const a = bytes[i]
53
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0
54
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0
55
+
56
+ const combined = (a << 16) | (b << 8) | c
57
+
58
+ result += chars[(combined >> 18) & 0x3f]
59
+ result += chars[(combined >> 12) & 0x3f]
60
+ result += i + 1 < bytes.length ? chars[(combined >> 6) & 0x3f] : "="
61
+ result += i + 2 < bytes.length ? chars[combined & 0x3f] : "="
62
+ }
63
+
64
+ return result
65
+ }
66
+
67
+ // Helper to normalize keys (lowercase only, no underscore conversion)
68
+ function normalizeKey(key) {
69
+ if (typeof key === "string") {
70
+ return key.toLowerCase()
71
+ }
72
+ return String(key).toLowerCase()
73
+ }
74
+
75
+ // Helper to check if a value is an ID (43 character base64url string)
76
+ function isId(value) {
77
+ return (
78
+ typeof value === "string" &&
79
+ value.length === 43 &&
80
+ /^[A-Za-z0-9_-]+$/.test(value)
81
+ )
82
+ }
83
+
84
+ // Helper to encode structured field dictionary
85
+ function encodeSfDict(dict) {
86
+ const items = []
87
+ for (const [key, value] of Object.entries(dict)) {
88
+ if (typeof value === "string") {
89
+ items.push(`${key}="${value}"`)
90
+ } else {
91
+ items.push(`${key}=${value}`)
92
+ }
93
+ }
94
+ return items.join(", ")
95
+ }
96
+
97
+ // Helper to parse structured field dictionary
98
+ function parseSfDict(str) {
99
+ const dict = {}
100
+ if (!str) return dict
101
+
102
+ const parts = str.split(/,\s*/)
103
+ for (const part of parts) {
104
+ const match = part.match(/^([^=]+)=(.+)$/)
105
+ if (match) {
106
+ const key = match[1].trim()
107
+ let value = match[2].trim()
108
+ if (value.startsWith('"') && value.endsWith('"')) {
109
+ value = value.slice(1, -1)
110
+ }
111
+ dict[key] = value
112
+ }
113
+ }
114
+ return dict
115
+ }
116
+
117
+ // Helper to generate boundary from parts using fast-sha256
118
+ function boundaryFromParts(parts) {
119
+ const bodyBin = parts.map(p => p.body).join(CRLF)
120
+ const hashBytes = hash(stringToBytes(bodyBin, "binary"))
121
+ return bytesToBase64url(hashBytes)
122
+ }
123
+
124
+ // Helper to determine inline key - matches Erlang's inline_key/2
125
+ function inlineKey(msg) {
126
+ // Check for ao-body-key (Erlang uses ao-body-key, not inline-body-key)
127
+ const aoBodyKey = msg["ao-body-key"]
128
+ if (aoBodyKey) {
129
+ return [{}, aoBodyKey]
130
+ }
131
+ if ("body" in msg) {
132
+ return [{}, "body"]
133
+ }
134
+ if ("data" in msg) {
135
+ return [{ "ao-body-key": "data" }, "data"]
136
+ }
137
+ return [{}, "body"]
138
+ }
139
+
140
+ // Group IDs into ao-ids field
141
+ function groupIds(map) {
142
+ const idDict = {}
143
+ const stripped = {}
144
+
145
+ for (const [key, value] of Object.entries(map)) {
146
+ if (isId(key) && typeof value === "string") {
147
+ // Store with lowercase key as in Erlang
148
+ idDict[key.toLowerCase()] = value
149
+ } else {
150
+ stripped[key] = value
151
+ }
152
+ }
153
+
154
+ if (Object.keys(idDict).length > 0) {
155
+ const items = []
156
+ for (const [k, v] of Object.entries(idDict)) {
157
+ items.push(`${k}="${v}"`)
158
+ }
159
+ stripped["ao-ids"] = items.join(", ")
160
+ }
161
+
162
+ // Return IDs as lowercase in the result since they will be normalized
163
+ // when processed as headers
164
+ for (const [k, v] of Object.entries(idDict)) {
165
+ stripped[k] = v
166
+ }
167
+
168
+ return stripped
169
+ }
170
+
171
+ // Ungroup IDs from ao-ids field
172
+ function ungroupIds(msg) {
173
+ if (!msg["ao-ids"]) return msg
174
+
175
+ const result = { ...msg }
176
+ delete result["ao-ids"]
177
+
178
+ const dict = parseSfDict(msg["ao-ids"])
179
+ for (const [key, value] of Object.entries(dict)) {
180
+ // ao-ids keys are lowercase, use them as-is
181
+ result[key] = value
182
+ }
183
+
184
+ return result
185
+ }
186
+
187
+ // Get the size of a map (matches Erlang's maps:size behavior)
188
+ // This counts ALL keys including ao-types - empty means literally {}
189
+ function mapSize(obj) {
190
+ if (typeof obj !== "object" || obj === null) return 0
191
+ return Object.keys(obj).length
192
+ }
193
+
194
+ // Group maps for body encoding - following Erlang logic exactly
195
+ function groupMaps(map, parent = "", top = {}) {
196
+ if (
197
+ typeof map !== "object" ||
198
+ map === null ||
199
+ Array.isArray(map) ||
200
+ Buffer.isBuffer(map)
201
+ ) {
202
+ return top
203
+ }
204
+
205
+ const flattened = {}
206
+ let newTop = { ...top }
207
+
208
+ // Process entries in sorted order for consistency
209
+ const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
210
+
211
+ for (const [key, value] of entries) {
212
+ // Normalize keys to lowercase
213
+ const normKey = normalizeKey(key)
214
+ const flatK = parent ? `${parent}/${normKey}` : normKey
215
+
216
+ if (
217
+ typeof value === "object" &&
218
+ value !== null &&
219
+ !Array.isArray(value) &&
220
+ !Buffer.isBuffer(value)
221
+ ) {
222
+ // Check size of the nested object (including metadata keys like ao-types)
223
+ // Empty means literally {} - a map with only ao-types is NOT empty
224
+ const size = mapSize(value)
225
+
226
+ if (size === 0) {
227
+ // Empty map (no data keys) - add empty-message marker
228
+ // This matches Erlang's group_maps behavior for empty maps
229
+ newTop[flatK] = { "ao-types": "empty-message" }
230
+ } else {
231
+ // Recursively process nested objects
232
+ newTop = groupMaps(value, flatK, newTop)
233
+ }
234
+ } else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
235
+ // Value too large for header, lift to top level
236
+ newTop[flatK] = value
237
+ } else if (Buffer.isBuffer(value) && value.length > MAX_HEADER_LENGTH) {
238
+ // Large buffers also get lifted to top level
239
+ newTop[flatK] = value
240
+ } else {
241
+ // Keep in current flattened map
242
+ flattened[normKey] = value
243
+ }
244
+ }
245
+
246
+ if (Object.keys(flattened).length === 0) {
247
+ return newTop
248
+ } else if (parent) {
249
+ // Add flattened map under parent key
250
+ return { ...newTop, [parent]: flattened }
251
+ } else {
252
+ // Merge flattened with top level
253
+ return { ...newTop, ...flattened }
254
+ }
255
+ }
256
+
257
+ // Helper to compute content-digest for a body value
258
+ function computePartDigest(bodyValue) {
259
+ let bodyBytes
260
+ if (Buffer.isBuffer(bodyValue)) {
261
+ bodyBytes = new Uint8Array(bodyValue)
262
+ } else if (typeof bodyValue === "string") {
263
+ bodyBytes = stringToBytes(bodyValue, "binary")
264
+ } else {
265
+ bodyBytes = stringToBytes(String(bodyValue), "binary")
266
+ }
267
+ const hashBytes = hash(bodyBytes)
268
+ return `sha-256=:${bytesToBase64(hashBytes)}:`
269
+ }
270
+
271
+ // Encode multipart body part
272
+ // NOTE: This matches Erlang's encode_body_part/4 which does NOT apply inline_key
273
+ // logic to nested parts. For nested maps, ALL fields become headers (except 'body'
274
+ // which becomes the part body). The inline_key logic is only for top-level messages.
275
+ function encodeBodyPart(partName, bodyPart, inlineKey) {
276
+ const disposition =
277
+ partName === inlineKey ? "inline" : `form-data;name="${partName}"`
278
+
279
+ if (
280
+ typeof bodyPart === "object" &&
281
+ bodyPart !== null &&
282
+ !Array.isArray(bodyPart) &&
283
+ !Buffer.isBuffer(bodyPart)
284
+ ) {
285
+ // Collect all headers (everything except 'body' and 'priv')
286
+ const allEntries = []
287
+
288
+ for (const [key, value] of Object.entries(bodyPart)) {
289
+ if (key === "body" || key === "priv") continue
290
+
291
+ // Handle Buffer values properly
292
+ let valueStr = value
293
+ if (Buffer.isBuffer(value)) {
294
+ // Use binary/latin1 encoding to preserve all byte values 0-255
295
+ valueStr = value.toString("binary")
296
+ }
297
+ allEntries.push({ key: key, line: `${key}: ${valueStr}` })
298
+ }
299
+
300
+ // Add content-disposition
301
+ allEntries.push({
302
+ key: "content-disposition",
303
+ line: `content-disposition: ${disposition}`,
304
+ })
305
+
306
+ // Sort all entries by key alphabetically - matches Erlang behavior
307
+ allEntries.sort((a, b) => a.key.localeCompare(b.key))
308
+
309
+ // Build the lines
310
+ const lines = allEntries.map(entry => entry.line)
311
+
312
+ // Only the 'body' field (if present) becomes the part body
313
+ const body = bodyPart.body
314
+ if (body !== "" && body !== undefined && body !== null) {
315
+ lines.push("") // Always add empty line before body
316
+ lines.push(Buffer.isBuffer(body) ? body.toString("binary") : String(body))
317
+ }
318
+
319
+ return lines.join(CRLF)
320
+ } else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
321
+ // Use binary/latin1 encoding to preserve byte values 0-255
322
+ const bodyStr = Buffer.isBuffer(bodyPart) ? bodyPart.toString("binary") : bodyPart
323
+ return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyStr}`
324
+ }
325
+ return ""
326
+ }
327
+
328
+ // Helper to detect if a Buffer contains binary data
329
+ function isBinaryData(buf) {
330
+ // Check first 512 bytes for binary indicators
331
+ const checkLength = Math.min(buf.length, 512)
332
+
333
+ // Any null byte means binary
334
+ for (let i = 0; i < checkLength; i++) {
335
+ if (buf[i] === 0) return true
336
+ }
337
+
338
+ // Count non-text bytes
339
+ let controlCount = 0
340
+ let highByteCount = 0
341
+
342
+ for (let i = 0; i < checkLength; i++) {
343
+ const byte = buf[i]
344
+ // Non-printable chars (except CR/LF/TAB)
345
+ if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
346
+ controlCount++
347
+ }
348
+ // High byte range
349
+ if (byte > 127) {
350
+ highByteCount++
351
+ }
352
+ }
353
+
354
+ // If more than 5% are control chars, likely binary
355
+ if (controlCount / checkLength > 0.05) return true
356
+
357
+ // If more than 20% are high bytes, likely binary
358
+ if (highByteCount / checkLength > 0.2) return true
359
+
360
+ return false
361
+ }
362
+
363
+ // Parse multipart body - handles binary data in headers
364
+ function parseMultipart(contentType, body) {
365
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/)
366
+ if (!boundaryMatch) return {}
367
+
368
+ const boundary = boundaryMatch[1]
369
+ const boundaryDelim = `--${boundary}`
370
+ const endBoundary = `--${boundary}--`
371
+
372
+ // Use binary encoding to preserve all bytes as character codes 0-255
373
+ let bodyContent = typeof body === "string" ? body : body.toString("binary")
374
+ if (bodyContent.endsWith(endBoundary)) {
375
+ bodyContent = bodyContent.substring(0, bodyContent.lastIndexOf(endBoundary))
376
+ }
377
+
378
+ const parts = bodyContent
379
+ .split(new RegExp(`\\r?\\n?${boundaryDelim}\\r?\\n`))
380
+ .filter(p => p && p.trim() && !p.startsWith("--"))
381
+
382
+ const result = {}
383
+ const bodyKeysList = []
384
+
385
+ for (const part of parts) {
386
+ // First split to find the headers/body boundary (double CRLF)
387
+ const headerBodySplit = part.indexOf(DOUBLE_CRLF)
388
+ let headerBlock, partBody
389
+
390
+ if (headerBodySplit !== -1) {
391
+ headerBlock = part.substring(0, headerBodySplit)
392
+ partBody = part.substring(headerBodySplit + DOUBLE_CRLF.length)
393
+ // Remove trailing CRLF from body
394
+ partBody = partBody.replace(/\r?\n?$/, "")
395
+ } else {
396
+ headerBlock = part
397
+ partBody = ""
398
+ }
399
+
400
+ const headers = {}
401
+
402
+ // Parse headers more carefully to handle binary data
403
+ let currentPos = 0
404
+ while (currentPos < headerBlock.length) {
405
+ // Find the next colon to identify a potential header
406
+ let colonPos = headerBlock.indexOf(": ", currentPos)
407
+
408
+ if (colonPos === -1) {
409
+ // No more headers
410
+ break
411
+ }
412
+
413
+ // Look backwards from colon to find the start of this line
414
+ let lineStart = currentPos
415
+ let searchBack = colonPos - 1
416
+ while (searchBack >= currentPos) {
417
+ if (headerBlock[searchBack] === "\n") {
418
+ lineStart = searchBack + 1
419
+ break
420
+ }
421
+ searchBack--
422
+ }
423
+
424
+ // Extract the header name
425
+ const name = headerBlock
426
+ .substring(lineStart, colonPos)
427
+ .trim()
428
+ .toLowerCase()
429
+
430
+ // Check if this looks like a valid header name
431
+ if (!/^[a-zA-Z0-9-]+$/.test(name) || name.length > 50) {
432
+ // Not a valid header, skip past this colon
433
+ currentPos = colonPos + 2
434
+ continue
435
+ }
436
+
437
+ // Start of value is after ": "
438
+ let valueStart = colonPos + 2
439
+
440
+ // Find the end of this header's value by looking for the next valid header
441
+ let valueEnd = headerBlock.length
442
+ let searchPos = valueStart
443
+
444
+ while (searchPos < headerBlock.length) {
445
+ let nextNewline = headerBlock.indexOf("\n", searchPos)
446
+ if (nextNewline === -1) break
447
+
448
+ let nextLineStart = nextNewline + 1
449
+ if (nextLineStart >= headerBlock.length) break
450
+
451
+ // Check if next line starts with a header pattern
452
+ let nextColon = headerBlock.indexOf(": ", nextLineStart)
453
+
454
+ if (nextColon > nextLineStart && nextColon < nextLineStart + 50) {
455
+ let possibleHeaderName = headerBlock
456
+ .substring(nextLineStart, nextColon)
457
+ .trim()
458
+
459
+ // Must be valid header name format
460
+ if (/^[a-zA-Z0-9-]+$/.test(possibleHeaderName)) {
461
+ valueEnd = nextNewline
462
+ break
463
+ }
464
+ }
465
+
466
+ searchPos = nextNewline + 1
467
+ }
468
+
469
+ // Extract the value - valueEnd points to \n which is NOT part of the value
470
+ let value = headerBlock.substring(valueStart, valueEnd)
471
+
472
+ // Determine if this is binary data FIRST (before trimming)
473
+ const isBinary =
474
+ value.length > 0 && isBinaryData(Buffer.from(value, "binary"))
475
+
476
+ if (isBinary) {
477
+ // Binary data: only remove trailing \r if it's part of CRLF separator
478
+ // (when valueEnd points to \n and value ends with \r)
479
+ if (
480
+ valueEnd < headerBlock.length &&
481
+ headerBlock[valueEnd] === "\n" &&
482
+ value.endsWith("\r")
483
+ ) {
484
+ value = value.substring(0, value.length - 1)
485
+ }
486
+ headers[name] = Buffer.from(value, "binary")
487
+ } else {
488
+ // Text data: remove all trailing CRLF/LF (these are line separators, not data)
489
+ value = value.replace(/[\r\n]+$/, "")
490
+ headers[name] = value
491
+ }
492
+
493
+ // Move to the end of this header's value
494
+ currentPos = valueEnd
495
+ if (currentPos < headerBlock.length && headerBlock[currentPos] === "\n") {
496
+ currentPos++
497
+ }
498
+ if (currentPos < headerBlock.length && headerBlock[currentPos] === "\r") {
499
+ currentPos++
500
+ }
501
+ }
502
+
503
+ const disposition = headers["content-disposition"]
504
+ if (!disposition) continue
505
+
506
+ let partName
507
+ if (disposition === "inline") {
508
+ // This is the inline part
509
+ partName = "body"
510
+ bodyKeysList.push("body")
511
+
512
+ // Extract all headers from inline part as top-level fields
513
+ const restHeaders = { ...headers }
514
+ delete restHeaders["content-disposition"]
515
+
516
+ // Add each header from the inline part to the top level of result
517
+ for (const [key, value] of Object.entries(restHeaders)) {
518
+ result[key] = value
519
+ }
520
+
521
+ // If there's body content in the inline part, add it as 'body'
522
+ if (partBody) {
523
+ // Keep as string unless it's binary data
524
+ result[partName] = isBinaryData(Buffer.from(partBody, "binary"))
525
+ ? Buffer.from(partBody, "binary")
526
+ : partBody
527
+ }
528
+ } else {
529
+ // Handle named form-data parts
530
+ const nameMatch = disposition.match(/name="([^"]+)"/)
531
+ partName = nameMatch ? nameMatch[1] : null
532
+ if (partName) {
533
+ const topLevelKey = partName.split("/")[0]
534
+ bodyKeysList.push(topLevelKey)
535
+ }
536
+
537
+ if (!partName) continue
538
+
539
+ const restHeaders = { ...headers }
540
+ delete restHeaders["content-disposition"]
541
+
542
+ if (Object.keys(restHeaders).length === 0) {
543
+ // Keep as string unless it's binary data
544
+ result[partName] = isBinaryData(Buffer.from(partBody, "binary"))
545
+ ? Buffer.from(partBody, "binary")
546
+ : partBody
547
+ } else if (!partBody) {
548
+ // ao-types should stay with this part, not be extracted
549
+ result[partName] = restHeaders
550
+ } else {
551
+ // Keep as string unless it's binary data
552
+ result[partName] = {
553
+ ...restHeaders,
554
+ body: isBinaryData(Buffer.from(partBody, "binary"))
555
+ ? Buffer.from(partBody, "binary")
556
+ : partBody,
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ if (bodyKeysList.length > 0) {
563
+ result["body-keys"] = bodyKeysList.map(k => `"${k}"`).join(", ")
564
+ }
565
+
566
+ return result
567
+ }
568
+
569
+ // Add content-digest header using fast-sha256
570
+ function addContentDigest(msg) {
571
+ if (!msg.body) return msg
572
+
573
+ let bodyBytes
574
+ // Handle both string and Buffer bodies
575
+ if (Buffer.isBuffer(msg.body)) {
576
+ bodyBytes = new Uint8Array(msg.body)
577
+ } else {
578
+ // For strings, use binary encoding to match how the multipart body is encoded
579
+ bodyBytes = stringToBytes(msg.body, "binary")
580
+ }
581
+
582
+ const hashBytes = hash(bodyBytes)
583
+ const digest = bytesToBase64(hashBytes)
584
+
585
+ return {
586
+ ...msg,
587
+ "content-digest": `sha-256=:${digest}:`,
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Convert HTTP message to TABM
593
+ */
594
+ export function httpsig_from(http) {
595
+ if (typeof http === "string") return http
596
+
597
+ const body = http.body || ""
598
+ const [inlinedFieldHdrs, inlinedKey] = inlineKey(http)
599
+ const headers = { ...http }
600
+ delete headers.body
601
+ delete headers["body-keys"]
602
+
603
+ const contentType = headers["content-type"]
604
+ let withBodyKeys = headers
605
+
606
+ // Parse multipart body if present
607
+ if (body && contentType && contentType.includes("multipart")) {
608
+ const parsed = parseMultipart(contentType, body)
609
+
610
+ // Handle the body-keys from the original HTTP message
611
+ if (http["body-keys"]) {
612
+ // Parse the existing body-keys and ensure they're in quoted format
613
+ const bodyKeys = http["body-keys"]
614
+ if (typeof bodyKeys === "string") {
615
+ if (!bodyKeys.includes('"')) {
616
+ // Convert unquoted format to quoted format
617
+ parsed["body-keys"] = bodyKeys
618
+ .split(/,\s*/)
619
+ .map(k => `"${k.trim()}"`)
620
+ .join(", ")
621
+ } else {
622
+ // Already quoted, use as-is
623
+ parsed["body-keys"] = bodyKeys
624
+ }
625
+ }
626
+ }
627
+
628
+ withBodyKeys = { ...headers, ...parsed }
629
+
630
+ // Convert flat structure to nested using flat.js
631
+ const flat = {}
632
+ const nonFlat = {}
633
+ for (const [key, value] of Object.entries(withBodyKeys)) {
634
+ if (key.includes("/")) {
635
+ flat[key] = value
636
+ } else {
637
+ nonFlat[key] = value
638
+ }
639
+ }
640
+
641
+ if (Object.keys(flat).length > 0) {
642
+ // Merge non-flat keys into flat for processing
643
+ // This ensures flat_from can see existing objects like results: { "ao-types": "..." }
644
+ const combined = { ...nonFlat, ...flat }
645
+
646
+ // Use flat_from to convert flat structure to nested
647
+ const nested = flat_from(combined)
648
+
649
+ // The nested result already has everything merged
650
+ withBodyKeys = nested
651
+ }
652
+ } else if (body) {
653
+ withBodyKeys[inlinedKey] = body
654
+ }
655
+
656
+ // Ungroup IDs
657
+ const withIds = ungroupIds(withBodyKeys)
658
+
659
+ // Remove signature-related headers and content-digest
660
+ const result = { ...withIds }
661
+ delete result.signature
662
+ delete result["signature-input"]
663
+ delete result.commitments
664
+ delete result["content-digest"]
665
+
666
+ // Extract hashpaths if any
667
+ for (const key of Object.keys(result)) {
668
+ if (key.startsWith("hashpath")) {
669
+ delete result[key]
670
+ }
671
+ }
672
+
673
+ return result
674
+ }
675
+
676
+ /**
677
+ * Convert TABM to HTTP message
678
+ * Implements bundle mode like Erlang's dev_codec_httpsig_conv:to/3 with bundle=true
679
+ */
680
+ export function httpsig_to(tabm) {
681
+ if (typeof tabm === "string") return tabm
682
+
683
+ // Bundle logic: TABM → structured → TABM
684
+ // This matches Erlang's behavior when bundle=true:
685
+ // 1. Convert TABM to structured@1.0 (interprets ao-types, decodes to native types)
686
+ // 2. Convert back to TABM (re-encodes with ao-types)
687
+ const structured = structuredTo(tabm)
688
+ const bundledTabm = structuredFrom(structured)
689
+
690
+ // Group IDs
691
+ const withGroupedIds = groupIds(bundledTabm)
692
+
693
+ // Remove private and signature-related keys
694
+ const stripped = { ...withGroupedIds }
695
+ delete stripped.commitments
696
+ delete stripped.signature
697
+ delete stripped["signature-input"]
698
+ delete stripped.priv
699
+
700
+ const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
701
+
702
+ // Check if this is a flat structure that should stay as headers
703
+ // A flat structure has no nested objects (maps), excluding:
704
+ // - Arrays (JS arrays, not numbered maps)
705
+ // - Buffers
706
+ // Note: List-encoded maps (numbered maps with .="list") ARE nested maps
707
+ // and should trigger multipart encoding, matching Erlang's behavior
708
+ const hasNestedMaps = Object.values(stripped).some(value => {
709
+ // Not an object
710
+ if (typeof value !== "object" || value === null) return false
711
+ // Arrays and Buffers are not nested maps
712
+ if (Array.isArray(value) || Buffer.isBuffer(value)) return false
713
+ // Any other object (including list-encoded maps) is a nested map
714
+ return true
715
+ })
716
+
717
+ // If it's just a flat map with strings/primitives, keep as headers
718
+ // This matches Erlang's behavior where flat maps don't become multipart
719
+ if (!hasNestedMaps) {
720
+ // For flat structures, just return with normalized keys
721
+ const result = { ...inlineFieldHdrs, ...stripped }
722
+
723
+ // Handle inline body key - move data from inline key to body
724
+ if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
725
+ result.body = result[inlineKeyVal]
726
+ delete result[inlineKeyVal]
727
+ }
728
+
729
+ // If the only field is ao-types (no actual data), return empty object
730
+ // This matches Erlang's behavior where ao-types-only messages become empty
731
+ const dataKeys = Object.keys(result).filter(k =>
732
+ k !== "ao-types" &&
733
+ k !== "ao-ids" &&
734
+ k !== "inline-body-key" &&
735
+ k !== "ao-body-key"
736
+ )
737
+ if (dataKeys.length === 0) {
738
+ return {}
739
+ }
740
+
741
+ // If there's a non-empty body, add content-digest
742
+ // Erlang doesn't add content-digest for empty bodies (<<>>)
743
+ if (result.body) {
744
+ const bodyIsEmpty = Buffer.isBuffer(result.body)
745
+ ? result.body.length === 0
746
+ : (typeof result.body === "string" && result.body.length === 0)
747
+
748
+ if (!bodyIsEmpty) {
749
+ return addContentDigest(result)
750
+ }
751
+ }
752
+
753
+ return result
754
+ }
755
+
756
+ // Original multipart logic for nested structures
757
+ const bodyMap = {}
758
+ const headers = { ...inlineFieldHdrs }
759
+
760
+ // Process each field - ao-types at top level should go to headers
761
+ for (const [key, value] of Object.entries(stripped)) {
762
+ if (key === "ao-types") {
763
+ // Top-level ao-types goes to headers only
764
+ // Keep as Buffer if it's a Buffer, otherwise use as-is
765
+ headers[key] = value
766
+ } else if (key === "body" || key === inlineKeyVal) {
767
+ bodyMap[key === inlineKeyVal ? inlineKeyVal : "body"] = value
768
+ } else if (
769
+ typeof value === "object" &&
770
+ value !== null &&
771
+ !Array.isArray(value) &&
772
+ !Buffer.isBuffer(value)
773
+ ) {
774
+ bodyMap[key] = value
775
+ } else if (
776
+ typeof value === "string" &&
777
+ value.length <= MAX_HEADER_LENGTH &&
778
+ key !== "ao-types"
779
+ ) {
780
+ headers[normalizeKey(key)] = value
781
+ } else if (
782
+ Buffer.isBuffer(value) &&
783
+ value.length <= MAX_HEADER_LENGTH &&
784
+ key !== "ao-types"
785
+ ) {
786
+ // Keep buffers as buffers for headers
787
+ headers[normalizeKey(key)] = value
788
+ } else if (key !== "ao-types") {
789
+ // Only add to bodyMap if it's not ao-types
790
+ bodyMap[key] = value
791
+ }
792
+ }
793
+
794
+ // Handle body encoding
795
+ const groupedBodyMap = groupMaps(bodyMap)
796
+
797
+ if (Object.keys(groupedBodyMap).length === 0) {
798
+ return headers
799
+ } else if (
800
+ Object.keys(groupedBodyMap).length === 1 &&
801
+ groupedBodyMap[inlineKeyVal] &&
802
+ typeof groupedBodyMap[inlineKeyVal] === "string"
803
+ ) {
804
+ const result = { ...headers, body: groupedBodyMap[inlineKeyVal] }
805
+ return addContentDigest(result)
806
+ } else {
807
+ // Multipart body
808
+ const parts = []
809
+ const bodyKeysList = []
810
+
811
+ const sortedEntries = Object.entries(groupedBodyMap).sort(([a], [b]) =>
812
+ a.localeCompare(b)
813
+ )
814
+
815
+ for (const [key, value] of sortedEntries) {
816
+ if (
817
+ typeof value === "object" &&
818
+ value !== null &&
819
+ Object.keys(value).length === 1 &&
820
+ "body" in value
821
+ ) {
822
+ const encoded = encodeBodyPart(`${key}/body`, value, "body")
823
+ parts.push({
824
+ name: `${key}/body`,
825
+ body: encoded,
826
+ })
827
+ bodyKeysList.push(key)
828
+ } else {
829
+ const encoded = encodeBodyPart(key, value, inlineKeyVal)
830
+ parts.push({
831
+ name: key,
832
+ body: encoded,
833
+ })
834
+ bodyKeysList.push(key)
835
+ }
836
+ }
837
+
838
+ const boundary = boundaryFromParts(parts)
839
+
840
+ const bodyParts = parts.map(p => `--${boundary}${CRLF}${p.body}`)
841
+ const finalBody = bodyParts.join(CRLF) + `${CRLF}--${boundary}--`
842
+
843
+ const result = {
844
+ ...headers,
845
+ // Note: body-keys is NOT included in httpsig output - it's only used for parsing
846
+ "content-type": `multipart/form-data; boundary="${boundary}"`,
847
+ body: finalBody,
848
+ }
849
+
850
+ return addContentDigest(result)
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Convert structured message to flat format
856
+ */
857
+ export function structured_to(msg) {
858
+ return msg
859
+ }
860
+
861
+ /**
862
+ * Convert flat format to structured message
863
+ */
864
+ export function structured_from(msg) {
865
+ return msg
866
+ }