hbsig 0.0.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.
Files changed (44) hide show
  1. package/cjs/bin_to_str.js +44 -0
  2. package/cjs/collect-body-keys.js +470 -0
  3. package/cjs/encode-array-item.js +110 -0
  4. package/cjs/encode-utils.js +236 -0
  5. package/cjs/encode.js +1318 -0
  6. package/cjs/erl_json.js +317 -0
  7. package/cjs/erl_str.js +1037 -0
  8. package/cjs/flat.js +222 -0
  9. package/cjs/http-message-signatures/httpbis.js +489 -0
  10. package/cjs/http-message-signatures/index.js +25 -0
  11. package/cjs/http-message-signatures/structured-header.js +129 -0
  12. package/cjs/httpsig.js +716 -0
  13. package/cjs/httpsig2.js +1160 -0
  14. package/cjs/id.js +470 -0
  15. package/cjs/index.js +63 -0
  16. package/cjs/send.js +194 -0
  17. package/cjs/signer-utils.js +617 -0
  18. package/cjs/signer.js +606 -0
  19. package/cjs/structured.js +296 -0
  20. package/cjs/test.js +27 -0
  21. package/cjs/utils.js +42 -0
  22. package/esm/bin_to_str.js +46 -0
  23. package/esm/collect-body-keys.js +436 -0
  24. package/esm/encode-array-item.js +112 -0
  25. package/esm/encode-utils.js +185 -0
  26. package/esm/encode.js +1219 -0
  27. package/esm/erl_json.js +289 -0
  28. package/esm/erl_str.js +1139 -0
  29. package/esm/flat.js +196 -0
  30. package/esm/http-message-signatures/httpbis.js +438 -0
  31. package/esm/http-message-signatures/index.js +4 -0
  32. package/esm/http-message-signatures/structured-header.js +105 -0
  33. package/esm/httpsig.js +658 -0
  34. package/esm/httpsig2.js +1097 -0
  35. package/esm/id.js +459 -0
  36. package/esm/index.js +4 -0
  37. package/esm/package.json +3 -0
  38. package/esm/send.js +124 -0
  39. package/esm/signer-utils.js +494 -0
  40. package/esm/signer.js +452 -0
  41. package/esm/structured.js +269 -0
  42. package/esm/test.js +6 -0
  43. package/esm/utils.js +28 -0
  44. package/package.json +28 -0
package/esm/httpsig.js ADDED
@@ -0,0 +1,658 @@
1
+ // httpsig.js - JavaScript implementation of HTTP Signature codec
2
+
3
+ import crypto from "crypto"
4
+ import { flat_from, flat_to } from "./flat.js"
5
+
6
+ const CRLF = "\r\n"
7
+ const DOUBLE_CRLF = CRLF + CRLF
8
+ const MAX_HEADER_LENGTH = 4096
9
+
10
+ // Helper to normalize keys (lowercase only, no underscore conversion)
11
+ function normalizeKey(key) {
12
+ if (typeof key === "string") {
13
+ return key.toLowerCase()
14
+ }
15
+ return String(key).toLowerCase()
16
+ }
17
+
18
+ // Helper to check if a value is an ID (43 character base64url string)
19
+ function isId(value) {
20
+ return (
21
+ typeof value === "string" &&
22
+ value.length === 43 &&
23
+ /^[A-Za-z0-9_-]+$/.test(value)
24
+ )
25
+ }
26
+
27
+ // Helper to encode structured field dictionary
28
+ function encodeSfDict(dict) {
29
+ const items = []
30
+ for (const [key, value] of Object.entries(dict)) {
31
+ if (typeof value === "string") {
32
+ items.push(`${key}="${value}"`)
33
+ } else {
34
+ items.push(`${key}=${value}`)
35
+ }
36
+ }
37
+ return items.join(", ")
38
+ }
39
+
40
+ // Helper to parse structured field dictionary
41
+ function parseSfDict(str) {
42
+ const dict = {}
43
+ if (!str) return dict
44
+
45
+ const parts = str.split(/,\s*/)
46
+ for (const part of parts) {
47
+ const match = part.match(/^([^=]+)=(.+)$/)
48
+ if (match) {
49
+ const key = match[1].trim()
50
+ let value = match[2].trim()
51
+ if (value.startsWith('"') && value.endsWith('"')) {
52
+ value = value.slice(1, -1)
53
+ }
54
+ dict[key] = value
55
+ }
56
+ }
57
+ return dict
58
+ }
59
+
60
+ // Helper to generate boundary from parts
61
+ function boundaryFromParts(parts) {
62
+ const bodyBin = parts.map(p => p.body).join(CRLF)
63
+ const hash = crypto.createHash("sha256")
64
+ hash.update(bodyBin)
65
+ return hash.digest("base64url")
66
+ }
67
+
68
+ // Helper to determine inline key
69
+ function inlineKey(msg) {
70
+ const inlineBodyKey = msg["inline-body-key"]
71
+ if (inlineBodyKey) {
72
+ return [{}, inlineBodyKey]
73
+ }
74
+ if ("body" in msg) {
75
+ return [{}, "body"]
76
+ }
77
+ if ("data" in msg) {
78
+ return [{ "inline-body-key": "data" }, "data"]
79
+ }
80
+ return [{}, "body"]
81
+ }
82
+
83
+ // Group IDs into ao-ids field
84
+ function groupIds(map) {
85
+ const idDict = {}
86
+ const stripped = {}
87
+
88
+ for (const [key, value] of Object.entries(map)) {
89
+ if (isId(key) && typeof value === "string") {
90
+ // Store with lowercase key as in Erlang
91
+ idDict[key.toLowerCase()] = value
92
+ } else {
93
+ stripped[key] = value
94
+ }
95
+ }
96
+
97
+ if (Object.keys(idDict).length > 0) {
98
+ const items = []
99
+ for (const [k, v] of Object.entries(idDict)) {
100
+ items.push(`${k}="${v}"`)
101
+ }
102
+ stripped["ao-ids"] = items.join(", ")
103
+ }
104
+
105
+ // Return IDs as lowercase in the result since they will be normalized
106
+ // when processed as headers
107
+ for (const [k, v] of Object.entries(idDict)) {
108
+ stripped[k] = v
109
+ }
110
+
111
+ return stripped
112
+ }
113
+
114
+ // Ungroup IDs from ao-ids field
115
+ function ungroupIds(msg) {
116
+ if (!msg["ao-ids"]) return msg
117
+
118
+ const result = { ...msg }
119
+ delete result["ao-ids"]
120
+
121
+ const dict = parseSfDict(msg["ao-ids"])
122
+ for (const [key, value] of Object.entries(dict)) {
123
+ // ao-ids keys are lowercase, use them as-is
124
+ result[key] = value
125
+ }
126
+
127
+ return result
128
+ }
129
+
130
+ // Group maps for body encoding - following Erlang logic exactly
131
+ function groupMaps(map, parent = "", top = {}) {
132
+ if (
133
+ typeof map !== "object" ||
134
+ map === null ||
135
+ Array.isArray(map) ||
136
+ Buffer.isBuffer(map)
137
+ ) {
138
+ return top
139
+ }
140
+
141
+ const flattened = {}
142
+ let newTop = { ...top }
143
+
144
+ // Process entries in sorted order for consistency
145
+ const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
146
+
147
+ for (const [key, value] of entries) {
148
+ // Normalize keys to lowercase
149
+ const normKey = normalizeKey(key)
150
+ const flatK = parent ? `${parent}/${normKey}` : normKey
151
+
152
+ if (
153
+ typeof value === "object" &&
154
+ value !== null &&
155
+ !Array.isArray(value) &&
156
+ !Buffer.isBuffer(value)
157
+ ) {
158
+ // Recursively process nested objects
159
+ newTop = groupMaps(value, flatK, newTop)
160
+ } else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
161
+ // Value too large for header, lift to top level
162
+ newTop[flatK] = value
163
+ } else if (Buffer.isBuffer(value) && value.length > MAX_HEADER_LENGTH) {
164
+ // Large buffers also get lifted to top level
165
+ newTop[flatK] = value
166
+ } else {
167
+ // Keep in current flattened map
168
+ flattened[normKey] = value
169
+ }
170
+ }
171
+
172
+ if (Object.keys(flattened).length === 0) {
173
+ return newTop
174
+ } else if (parent) {
175
+ // Add flattened map under parent key
176
+ return { ...newTop, [parent]: flattened }
177
+ } else {
178
+ // Merge flattened with top level
179
+ return { ...newTop, ...flattened }
180
+ }
181
+ }
182
+
183
+ // Encode multipart body part
184
+ function encodeBodyPart(partName, bodyPart, inlineKey) {
185
+ const disposition =
186
+ partName === inlineKey ? "inline" : `form-data;name="${partName}"`
187
+ const isInline = partName === inlineKey
188
+
189
+ if (
190
+ typeof bodyPart === "object" &&
191
+ bodyPart !== null &&
192
+ !Array.isArray(bodyPart) &&
193
+ !Buffer.isBuffer(bodyPart)
194
+ ) {
195
+ // Check if this part has ao-types
196
+ const hasAoTypes = "ao-types" in bodyPart
197
+
198
+ if (hasAoTypes) {
199
+ // For parts WITH ao-types: sort all entries alphabetically
200
+ const allEntries = []
201
+
202
+ // Collect all entries except body
203
+ for (const [key, value] of Object.entries(bodyPart)) {
204
+ if (key === "body") continue
205
+
206
+ if (key === "ao-types") {
207
+ allEntries.push({ key: "ao-types", line: `ao-types: ${value}` })
208
+ } else {
209
+ // Handle Buffer values properly
210
+ let valueStr = value
211
+ if (Buffer.isBuffer(value)) {
212
+ // Use binary/latin1 encoding to preserve all byte values 0-255
213
+ valueStr = value.toString("binary")
214
+ }
215
+ allEntries.push({ key: key, line: `${key}: ${valueStr}` })
216
+ }
217
+ }
218
+
219
+ // Add content-disposition
220
+ allEntries.push({
221
+ key: "content-disposition",
222
+ line: `content-disposition: ${disposition}`,
223
+ })
224
+
225
+ // Sort alphabetically by key
226
+ allEntries.sort((a, b) => a.key.localeCompare(b.key))
227
+
228
+ // Build the lines
229
+ const lines = allEntries.map(entry => entry.line)
230
+
231
+ // Body handling
232
+ const body = bodyPart.body || ""
233
+ if (body) {
234
+ lines.push("") // Always add empty line before body
235
+ lines.push(body)
236
+ }
237
+
238
+ return lines.join(CRLF)
239
+ } else {
240
+ // For parts WITHOUT ao-types
241
+ const allEntries = []
242
+
243
+ for (const [key, value] of Object.entries(bodyPart)) {
244
+ if (key === "body") continue
245
+ // Handle Buffer values properly
246
+ let valueStr = value
247
+ if (Buffer.isBuffer(value)) {
248
+ // Use binary/latin1 encoding to preserve all byte values 0-255
249
+ valueStr = value.toString("binary")
250
+ }
251
+ allEntries.push({ key: key, line: `${key}: ${valueStr}` })
252
+ }
253
+
254
+ const lines = []
255
+
256
+ if (isInline) {
257
+ // Inline parts without ao-types: sort ALL fields alphabetically including content-disposition
258
+ allEntries.push({
259
+ key: "content-disposition",
260
+ line: `content-disposition: ${disposition}`,
261
+ })
262
+
263
+ // Sort by key
264
+ allEntries.sort((a, b) => a.key.localeCompare(b.key))
265
+
266
+ // Extract the lines
267
+ lines.push(...allEntries.map(entry => entry.line))
268
+ } else {
269
+ // Regular parts: content-disposition first, then fields
270
+ lines.push(`content-disposition: ${disposition}`)
271
+ lines.push(...allEntries.map(entry => entry.line))
272
+ }
273
+
274
+ // Body handling
275
+ const body = bodyPart.body || ""
276
+ if (body) {
277
+ lines.push("") // Always add empty line before body
278
+ lines.push(body)
279
+ }
280
+
281
+ return lines.join(CRLF)
282
+ }
283
+ } else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
284
+ return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyPart}`
285
+ }
286
+ return ""
287
+ }
288
+
289
+ // Parse multipart body
290
+ function parseMultipart(contentType, body) {
291
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/)
292
+ if (!boundaryMatch) return {}
293
+
294
+ const boundary = boundaryMatch[1]
295
+ const boundaryDelim = `--${boundary}`
296
+ const endBoundary = `--${boundary}--`
297
+
298
+ // Remove the final boundary terminator if present
299
+ let bodyContent = body
300
+ if (bodyContent.endsWith(endBoundary)) {
301
+ bodyContent = bodyContent.substring(0, bodyContent.lastIndexOf(endBoundary))
302
+ }
303
+
304
+ const parts = bodyContent
305
+ .split(new RegExp(`\\r?\\n?${boundaryDelim}\\r?\\n`))
306
+ .filter(p => p && p.trim() && !p.startsWith("--"))
307
+
308
+ const result = {}
309
+ const bodyKeysList = []
310
+
311
+ for (const part of parts) {
312
+ const [headerBlock, ...bodyParts] = part.split(DOUBLE_CRLF)
313
+ let partBody = bodyParts.join(DOUBLE_CRLF)
314
+
315
+ // Remove trailing CRLF
316
+ partBody = partBody.replace(/\r?\n?$/, "")
317
+
318
+ const headers = {}
319
+ const headerLines = headerBlock.split(/\r?\n/)
320
+ for (const line of headerLines) {
321
+ const colonIndex = line.indexOf(": ")
322
+ if (colonIndex > 0) {
323
+ const name = line.substring(0, colonIndex).toLowerCase()
324
+ const value = line.substring(colonIndex + 2)
325
+ headers[name] = value
326
+ }
327
+ }
328
+
329
+ const disposition = headers["content-disposition"]
330
+ if (!disposition) continue
331
+
332
+ let partName
333
+ if (disposition === "inline") {
334
+ partName = "body"
335
+ bodyKeysList.push("body")
336
+ } else {
337
+ const nameMatch = disposition.match(/name="([^"]+)"/)
338
+ partName = nameMatch ? nameMatch[1] : null
339
+ if (partName) {
340
+ // Add the top-level key for this part
341
+ const topLevelKey = partName.split("/")[0]
342
+ bodyKeysList.push(topLevelKey)
343
+ }
344
+ }
345
+
346
+ if (!partName) continue
347
+
348
+ const restHeaders = { ...headers }
349
+ delete restHeaders["content-disposition"]
350
+
351
+ if (Object.keys(restHeaders).length === 0) {
352
+ result[partName] = partBody
353
+ } else if (!partBody) {
354
+ result[partName] = restHeaders
355
+ } else {
356
+ result[partName] = { ...restHeaders, body: partBody }
357
+ }
358
+ }
359
+
360
+ if (bodyKeysList.length > 0) {
361
+ // Format as structured field list, preserving order and duplicates
362
+ result["body-keys"] = bodyKeysList.map(k => `"${k}"`).join(", ")
363
+ }
364
+
365
+ return result
366
+ }
367
+
368
+ // Add content-digest header
369
+ function addContentDigest(msg) {
370
+ if (!msg.body) return msg
371
+
372
+ const hash = crypto.createHash("sha256")
373
+
374
+ // Handle both string and Buffer bodies
375
+ if (Buffer.isBuffer(msg.body)) {
376
+ hash.update(msg.body)
377
+ } else {
378
+ // For strings, use binary encoding to match how the multipart body is encoded
379
+ hash.update(msg.body, "binary")
380
+ }
381
+
382
+ const digest = hash.digest("base64")
383
+
384
+ return {
385
+ ...msg,
386
+ "content-digest": `sha-256=:${digest}:`,
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Convert HTTP message to TABM
392
+ */
393
+ export function httpsig_from(http) {
394
+ if (typeof http === "string") return http
395
+
396
+ const body = http.body || ""
397
+ const [inlinedFieldHdrs, inlinedKey] = inlineKey(http)
398
+ const headers = { ...http }
399
+ delete headers.body
400
+ delete headers["body-keys"]
401
+
402
+ const contentType = headers["content-type"]
403
+ let withBodyKeys = headers
404
+
405
+ // Parse multipart body if present
406
+ if (body && contentType && contentType.includes("multipart")) {
407
+ const parsed = parseMultipart(contentType, body)
408
+
409
+ // Handle the body-keys from the original HTTP message
410
+ if (http["body-keys"]) {
411
+ // Parse the existing body-keys and ensure they're in quoted format
412
+ const bodyKeys = http["body-keys"]
413
+ if (typeof bodyKeys === "string") {
414
+ if (!bodyKeys.includes('"')) {
415
+ // Convert unquoted format to quoted format
416
+ parsed["body-keys"] = bodyKeys
417
+ .split(/,\s*/)
418
+ .map(k => `"${k.trim()}"`)
419
+ .join(", ")
420
+ } else {
421
+ // Already quoted, use as-is
422
+ parsed["body-keys"] = bodyKeys
423
+ }
424
+ }
425
+ }
426
+
427
+ withBodyKeys = { ...headers, ...parsed }
428
+
429
+ // Convert flat structure to nested using flat.js
430
+ const flat = {}
431
+ for (const [key, value] of Object.entries(withBodyKeys)) {
432
+ if (key.includes("/")) {
433
+ flat[key] = value
434
+ }
435
+ }
436
+
437
+ if (Object.keys(flat).length > 0) {
438
+ // Use flat_from to convert flat structure to nested
439
+ const nested = flat_from(flat)
440
+ for (const [key, value] of Object.entries(nested)) {
441
+ withBodyKeys[key] = value
442
+ }
443
+ for (const key of Object.keys(flat)) {
444
+ delete withBodyKeys[key]
445
+ }
446
+ }
447
+ } else if (body) {
448
+ withBodyKeys[inlinedKey] = body
449
+ }
450
+
451
+ // Ungroup IDs
452
+ const withIds = ungroupIds(withBodyKeys)
453
+
454
+ // Remove signature-related headers and content-digest
455
+ const result = { ...withIds }
456
+ delete result.signature
457
+ delete result["signature-input"]
458
+ delete result.commitments
459
+ delete result["content-digest"]
460
+
461
+ // Extract hashpaths if any
462
+ for (const key of Object.keys(result)) {
463
+ if (key.startsWith("hashpath")) {
464
+ delete result[key]
465
+ }
466
+ }
467
+
468
+ return result
469
+ }
470
+
471
+ /**
472
+ * Convert TABM to HTTP message
473
+ */
474
+ export function httpsig_to(tabm) {
475
+ if (typeof tabm === "string") return tabm
476
+
477
+ // Group IDs
478
+ const withGroupedIds = groupIds(tabm)
479
+
480
+ // Remove private and signature-related keys
481
+ const stripped = { ...withGroupedIds }
482
+ delete stripped.commitments
483
+ delete stripped.signature
484
+ delete stripped["signature-input"]
485
+ delete stripped.priv
486
+
487
+ const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
488
+
489
+ // Check if this is a flat structure that should stay as headers
490
+ // A flat structure has no nested objects (maps)
491
+ const hasNestedMaps = Object.values(stripped).some(
492
+ value =>
493
+ typeof value === "object" &&
494
+ value !== null &&
495
+ !Array.isArray(value) &&
496
+ !Buffer.isBuffer(value)
497
+ )
498
+
499
+ // If it's just a flat map with strings/primitives, keep as headers
500
+ // This matches Erlang's behavior where flat maps don't become multipart
501
+ if (!hasNestedMaps) {
502
+ // For flat structures, just return with normalized keys
503
+ // This matches Erlang which returns the map unchanged
504
+ const result = { ...inlineFieldHdrs }
505
+
506
+ for (const [key, value] of Object.entries(stripped)) {
507
+ // Convert Buffers to strings if they're UTF-8 text
508
+ if (Buffer.isBuffer(value)) {
509
+ try {
510
+ const str = value.toString("utf8")
511
+ // Check if it's valid UTF-8 that can be safely converted
512
+ if (Buffer.from(str, "utf8").equals(value)) {
513
+ // Check if all characters are printable
514
+ let isPrintable = true
515
+ for (let i = 0; i < str.length; i++) {
516
+ const code = str.charCodeAt(i)
517
+ if (
518
+ !(code >= 32 && code <= 126) &&
519
+ code !== 9 &&
520
+ code !== 10 &&
521
+ code !== 13
522
+ ) {
523
+ isPrintable = false
524
+ break
525
+ }
526
+ }
527
+ if (isPrintable) {
528
+ result[key] = str
529
+ } else {
530
+ result[key] = value
531
+ }
532
+ } else {
533
+ result[key] = value
534
+ }
535
+ } catch (e) {
536
+ result[key] = value
537
+ }
538
+ } else {
539
+ result[key] = value
540
+ }
541
+ }
542
+
543
+ // Handle inline body key - move data from inline key to body
544
+ if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
545
+ result.body = result[inlineKeyVal]
546
+ delete result[inlineKeyVal]
547
+ }
548
+
549
+ // If there's a body, add content-digest
550
+ if (result.body) {
551
+ return addContentDigest(result)
552
+ }
553
+
554
+ return result
555
+ }
556
+
557
+ // Original multipart logic for nested structures
558
+ const bodyMap = {}
559
+ const headers = { ...inlineFieldHdrs }
560
+
561
+ // Process each field - ao-types at top level should go to headers
562
+ for (const [key, value] of Object.entries(stripped)) {
563
+ if (key === "ao-types") {
564
+ // Top-level ao-types goes to headers only
565
+ // Convert Buffer to string if needed
566
+ if (Buffer.isBuffer(value)) {
567
+ headers[key] = value.toString("utf8")
568
+ } else {
569
+ headers[key] = value
570
+ }
571
+ } else if (key === "body" || key === inlineKeyVal) {
572
+ bodyMap[key === inlineKeyVal ? inlineKeyVal : "body"] = value
573
+ } else if (
574
+ typeof value === "object" &&
575
+ value !== null &&
576
+ !Array.isArray(value) &&
577
+ !Buffer.isBuffer(value)
578
+ ) {
579
+ bodyMap[key] = value
580
+ } else if (
581
+ typeof value === "string" &&
582
+ value.length <= MAX_HEADER_LENGTH &&
583
+ key !== "ao-types"
584
+ ) {
585
+ headers[normalizeKey(key)] = value
586
+ } else if (
587
+ Buffer.isBuffer(value) &&
588
+ value.length <= MAX_HEADER_LENGTH &&
589
+ key !== "ao-types"
590
+ ) {
591
+ // Convert Buffers to strings for headers
592
+ const str = value.toString("utf8")
593
+ headers[normalizeKey(key)] = str
594
+ } else if (key !== "ao-types") {
595
+ // Only add to bodyMap if it's not ao-types
596
+ bodyMap[key] = value
597
+ }
598
+ }
599
+
600
+ // Handle body encoding
601
+ const groupedBodyMap = groupMaps(bodyMap)
602
+
603
+ if (Object.keys(groupedBodyMap).length === 0) {
604
+ return headers
605
+ } else if (
606
+ Object.keys(groupedBodyMap).length === 1 &&
607
+ groupedBodyMap[inlineKeyVal] &&
608
+ typeof groupedBodyMap[inlineKeyVal] === "string"
609
+ ) {
610
+ const result = { ...headers, body: groupedBodyMap[inlineKeyVal] }
611
+ return addContentDigest(result)
612
+ } else {
613
+ // Multipart body
614
+ const parts = []
615
+ const bodyKeysList = []
616
+
617
+ const sortedEntries = Object.entries(groupedBodyMap).sort(([a], [b]) =>
618
+ a.localeCompare(b)
619
+ )
620
+
621
+ for (const [key, value] of sortedEntries) {
622
+ if (
623
+ typeof value === "object" &&
624
+ value !== null &&
625
+ Object.keys(value).length === 1 &&
626
+ "body" in value
627
+ ) {
628
+ const encoded = encodeBodyPart(`${key}/body`, value, "body")
629
+ parts.push({
630
+ name: `${key}/body`,
631
+ body: encoded,
632
+ })
633
+ bodyKeysList.push(key)
634
+ } else {
635
+ const encoded = encodeBodyPart(key, value, inlineKeyVal)
636
+ parts.push({
637
+ name: key,
638
+ body: encoded,
639
+ })
640
+ bodyKeysList.push(key)
641
+ }
642
+ }
643
+
644
+ const boundary = boundaryFromParts(parts)
645
+
646
+ const bodyParts = parts.map(p => `--${boundary}${CRLF}${p.body}`)
647
+ const finalBody = bodyParts.join(CRLF) + `${CRLF}--${boundary}--`
648
+
649
+ const result = {
650
+ ...headers,
651
+ "body-keys": bodyKeysList.map(k => `"${k}"`).join(", "),
652
+ "content-type": `multipart/form-data; boundary="${boundary}"`,
653
+ body: finalBody,
654
+ }
655
+
656
+ return addContentDigest(result)
657
+ }
658
+ }