wao 0.24.2 → 0.25.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/esm/signer.js CHANGED
@@ -10,23 +10,79 @@ const {
10
10
  formatSignatureBase,
11
11
  } = httpbis
12
12
 
13
- /**
14
- * Convert value to Buffer
15
- */
13
+ export function hbEncodeValue(value) {
14
+ if (isBytes(value)) {
15
+ if (value.byteLength === 0) return hbEncodeValue("")
16
+ return [undefined, value]
17
+ }
18
+
19
+ if (typeof value === "string") {
20
+ if (value.length === 0) return ["empty-binary", undefined]
21
+ return [undefined, value]
22
+ }
23
+
24
+ if (Array.isArray(value)) {
25
+ if (value.length === 0) return ["empty-list", undefined]
26
+ if (value.some(isPojo)) {
27
+ throw new Error(
28
+ `Array with objects should have been lifted: ${JSON.stringify(value)}`
29
+ )
30
+ }
31
+
32
+ const encoded = value
33
+ .map(v => {
34
+ if (typeof v === "string") {
35
+ if (v === "") return `"(ao-type-empty-binary) "`
36
+ const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
37
+ return `"${escaped}"`
38
+ } else if (typeof v === "number") return String(v)
39
+ else if (typeof v === "boolean") return v ? "?1" : "?0"
40
+ else if (typeof v === "symbol") {
41
+ const desc = v.description || "symbol"
42
+ const escaped = desc.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
43
+ return `"(ao-type-atom) ${escaped}"`
44
+ } else if (v === null) return `"(ao-type-atom) null"`
45
+ else if (v === undefined) return `"(ao-type-atom) undefined"`
46
+ else if (Array.isArray(v) && v.length === 0) {
47
+ return `"(ao-type-empty-list) "`
48
+ }
49
+ return `"${String(v)}"`
50
+ })
51
+ .join(", ")
52
+ return ["list", encoded]
53
+ }
54
+
55
+ if (typeof value === "number") {
56
+ if (!Number.isInteger(value)) return ["float", `${value}`]
57
+ return ["integer", String(value)]
58
+ }
59
+
60
+ if (typeof value === "boolean") {
61
+ return ["atom", `"${value ? "true" : "false"}"`]
62
+ }
63
+
64
+ if (typeof value === "symbol") {
65
+ const desc = value.description || "symbol"
66
+ return ["atom", `"${desc}"`]
67
+ }
68
+
69
+ if (value === null) return ["atom", `"null"`]
70
+
71
+ if (value === undefined) return ["atom", `"undefined"`]
72
+
73
+ throw new Error(`Cannot encode value: ${String(value)}`)
74
+ }
75
+
16
76
  const toView = value => {
17
77
  if (ArrayBuffer.isView(value)) {
18
78
  return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
19
- } else if (typeof value === "string") {
20
- return base64url.toBuffer(value)
21
- }
79
+ } else if (typeof value === "string") return base64url.toBuffer(value)
80
+
22
81
  throw new Error(
23
82
  "Value must be Uint8Array, ArrayBuffer, or base64url-encoded string"
24
83
  )
25
84
  }
26
85
 
27
- /**
28
- * Generate HTTP signature name from address
29
- */
30
86
  const httpSigName = address => {
31
87
  const decoded = base64url.toBuffer(address)
32
88
  const hexString = [...decoded.subarray(1, 9)]
@@ -35,24 +91,22 @@ const httpSigName = address => {
35
91
  return `http-sig-${hexString}`
36
92
  }
37
93
 
38
- /**
39
- * Join URL parts
40
- */
41
94
  const joinUrl = ({ url, path }) => {
42
- // If path is already a full URL, return it as-is
43
95
  if (path.startsWith("http://") || path.startsWith("https://")) {
44
96
  return path
45
97
  }
46
-
47
- // Otherwise, join the base URL with the path
48
98
  return url.endsWith("/") ? url.slice(0, -1) + path : url + path
49
99
  }
50
100
 
51
- /**
52
- * HyperBEAM Encoding Logic
53
- */
54
101
  const MAX_HEADER_LENGTH = 4096
55
102
 
103
+ function encode_body_keys(bodyKeys) {
104
+ if (!bodyKeys || bodyKeys.length === 0) return ""
105
+ const items = bodyKeys.map(key => `"${key}"`)
106
+ const result = items.join(", ")
107
+ return result
108
+ }
109
+
56
110
  async function hasNewline(value) {
57
111
  if (typeof value === "string") return value.includes("\n")
58
112
  if (value instanceof Blob) {
@@ -81,171 +135,171 @@ function isPojo(value) {
81
135
  )
82
136
  }
83
137
 
84
- function hbEncodeValue(value) {
85
- if (isBytes(value)) {
86
- if (value.byteLength === 0) return hbEncodeValue("")
87
- return [undefined, value]
88
- }
89
-
90
- if (typeof value === "string") {
91
- if (value.length === 0) return [undefined, "empty-binary"]
92
- return [undefined, value]
93
- }
94
-
95
- if (Array.isArray(value)) {
96
- if (value.length === 0) return ["empty-list", undefined]
97
- // For structured fields, just join the string values
98
- const encoded = value
99
- .map(v => {
100
- if (typeof v === "string") {
101
- // Escape quotes and backslashes
102
- const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
103
- return `"${escaped}"`
104
- } else if (typeof v === "number") {
105
- // Numbers should be encoded as bare items, not strings
106
- return String(v)
107
- } else if (typeof v === "boolean") {
108
- // Booleans as structured field tokens
109
- return v ? "?1" : "?0"
110
- }
111
- return `"${String(v)}"`
112
- })
113
- .join(", ")
114
- return ["list", encoded]
115
- }
116
-
117
- if (typeof value === "number") {
118
- if (!Number.isInteger(value)) return ["float", `${value}`]
119
- return ["integer", String(value)]
120
- }
121
-
122
- if (typeof value === "symbol") {
123
- return ["atom", value.description]
124
- }
125
-
126
- throw new Error(`Cannot encode value: ${value.toString()}`)
127
- }
128
-
129
138
  function hbEncodeLift(obj, parent = "", top = {}) {
130
139
  const [flattened, types] = Object.entries({ ...obj }).reduce(
131
140
  (acc, [key, value]) => {
132
- // For nested paths, preserve casing. For top-level, also preserve casing
133
141
  const storageKey = parent ? `${parent}/${key}` : key
134
-
135
- // skip nullish values
136
- if (value == null) return acc
137
-
138
- // list of objects
139
- if (Array.isArray(value) && value.some(isPojo)) {
140
- value = value.reduce(
141
- (indexedObj, v, idx) => Object.assign(indexedObj, { [idx]: v }),
142
- {}
143
- )
142
+ if (value == null) {
143
+ const [type, encoded] = hbEncodeValue(value)
144
+ if (encoded !== undefined) acc[0][key] = encoded
145
+ if (type) acc[1][key.toLowerCase()] = type
146
+ return acc
147
+ }
148
+ if (Array.isArray(value)) {
149
+ const hasObjects = value.some(isPojo)
150
+ const hasBinary = value.some(isBytes)
151
+ if (hasObjects || hasBinary) {
152
+ const indexedObj = value.reduce(
153
+ (obj, v, idx) => Object.assign(obj, { [idx]: v }),
154
+ {}
155
+ )
156
+ acc[1][key.toLowerCase()] = "list"
157
+ hbEncodeLift(indexedObj, storageKey, top)
158
+ return acc
159
+ } else {
160
+ const [type, encoded] = hbEncodeValue(value)
161
+ if (type) acc[1][key.toLowerCase()] = type
162
+ if (encoded !== undefined) acc[0][key] = encoded
163
+ return acc
164
+ }
144
165
  }
145
166
 
146
- // Store the original value for reference
147
167
  const originalValue = value
148
168
 
149
- // first/second lift object - handle nested objects
150
169
  if (isPojo(value)) {
151
- // Check if this object has any nested objects or arrays with objects
170
+ if (Object.keys(value).length === 0) {
171
+ acc[1][key.toLowerCase()] = "empty-message"
172
+ return acc
173
+ }
174
+
152
175
  const hasComplexValues = Object.values(value).some(
153
176
  v => isPojo(v) || (Array.isArray(v) && v.some(item => isPojo(item)))
154
177
  )
155
178
 
156
179
  if (!hasComplexValues) {
157
- // Simple flat object - can be encoded as structured field dictionary
158
180
  const items = []
181
+ const hasAnyNonEmptyValues = Object.values(value).some(v => {
182
+ return !(
183
+ v === null ||
184
+ v === undefined ||
185
+ v === "" ||
186
+ (Array.isArray(v) && v.length === 0) ||
187
+ (isPojo(v) && Object.keys(v).length === 0)
188
+ )
189
+ })
159
190
 
160
191
  Object.entries(value).forEach(([k, v]) => {
161
192
  const subKey = k.toLowerCase()
162
193
 
163
- if (typeof v === "string") {
164
- const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
165
- items.push(`${subKey}="${escaped}"`)
194
+ if (v === null) {
195
+ items.push(`${subKey}="null"`)
196
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "atom"
197
+ } else if (v === undefined) {
198
+ items.push(`${subKey}="undefined"`)
199
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "atom"
200
+ } else if (typeof v === "string") {
201
+ if (v === "") {
202
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "empty-binary"
203
+ } else {
204
+ const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
205
+ items.push(`${subKey}="${escaped}"`)
206
+ }
166
207
  } else if (typeof v === "number") {
167
208
  items.push(`${subKey}=${v}`)
168
209
  if (Number.isInteger(v)) {
169
- // Use URL-encoded forward slash separator
170
210
  acc[1][`${key.toLowerCase()}%2f${subKey}`] = "integer"
171
211
  } else {
172
212
  acc[1][`${key.toLowerCase()}%2f${subKey}`] = "float"
173
213
  }
174
214
  } else if (typeof v === "boolean") {
175
215
  items.push(`${subKey}=${v ? "?1" : "?0"}`)
216
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "boolean"
217
+ } else if (typeof v === "symbol") {
218
+ const desc = v.description || "symbol"
219
+ const escaped = desc.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
220
+ items.push(`${subKey}="${escaped}"`)
221
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "atom"
176
222
  } else if (Array.isArray(v) && !v.some(item => isPojo(item))) {
177
- // Simple array (no objects) - encode as structured field inner list
178
- const listItems = v.map(item => {
179
- if (typeof item === "string") {
180
- return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
181
- } else if (typeof item === "number") {
182
- return String(item)
183
- } else if (typeof item === "boolean") {
184
- return item ? "?1" : "?0"
185
- } else {
186
- return `"${String(item)}"`
187
- }
188
- })
189
- items.push(`${subKey}=(${listItems.join(" ")})`)
223
+ if (v.length === 0) {
224
+ items.push(`${subKey}=()`)
225
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "empty-list"
226
+ } else {
227
+ const listItems = v.map(item => {
228
+ if (typeof item === "string") {
229
+ return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
230
+ } else if (typeof item === "number") {
231
+ return String(item)
232
+ } else if (typeof item === "boolean") {
233
+ return item ? "?1" : "?0"
234
+ } else if (typeof item === "symbol") {
235
+ const desc = item.description || "symbol"
236
+ return `"${desc.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
237
+ } else if (item === null) {
238
+ return `"null"`
239
+ } else if (item === undefined) {
240
+ return `"undefined"`
241
+ } else {
242
+ return `"${String(item)}"`
243
+ }
244
+ })
245
+ items.push(`${subKey}=(${listItems.join(" ")})`)
246
+ }
247
+ } else if (isPojo(v) && Object.keys(v).length === 0) {
248
+ items.push(`${subKey}`)
249
+ acc[1][`${key.toLowerCase()}%2f${subKey}`] = "empty-message"
190
250
  }
191
251
  })
192
252
 
193
253
  const encodedValue = items.join(", ")
194
- acc[0][key] = encodedValue
195
- acc[1][key.toLowerCase()] = "map"
196
- } else {
197
- // Has nested objects - needs multipart encoding
198
- hbEncodeLift(value, storageKey, top)
199
- // Add the original object to flattened so it can be processed
200
- acc[0][key] = value
201
- }
202
254
 
255
+ const hasOnlyEmptyValues = Object.entries(value).every(([k, v]) => {
256
+ return (
257
+ v === null ||
258
+ v === undefined ||
259
+ v === "" ||
260
+ (Array.isArray(v) && v.length === 0) ||
261
+ (isPojo(v) && Object.keys(v).length === 0)
262
+ )
263
+ })
264
+
265
+ if (!hasAnyNonEmptyValues) {
266
+ acc[1][key.toLowerCase()] = "map"
267
+ } else if (encodedValue === "") {
268
+ acc[1][key.toLowerCase()] = "empty-message"
269
+ } else {
270
+ acc[0][key] = encodedValue
271
+ acc[1][key.toLowerCase()] = "map"
272
+ }
273
+ } else hbEncodeLift(value, storageKey, top)
203
274
  return acc
204
275
  }
205
276
 
206
- // leaf encode value
207
277
  const [type, encoded] = hbEncodeValue(value)
278
+
208
279
  if (encoded !== undefined) {
209
- // For binary data, check the byte length directly without converting to string
210
- const byteLength = isBytes(encoded)
211
- ? encoded.byteLength
212
- : Buffer.from(String(encoded)).byteLength
213
-
214
- if (byteLength > MAX_HEADER_LENGTH) {
215
- // Store large values, but preserve binary data as-is
216
- top[storageKey] = isBytes(encoded) ? encoded : String(encoded)
217
- } else {
218
- // Preserve the original key casing
219
- const httpKey = key
220
- if (type === "integer" && typeof value === "number") {
221
- acc[0][httpKey] = String(value)
222
- } else {
223
- acc[0][httpKey] = encoded
224
- }
280
+ if (isBytes(encoded)) top[storageKey] = encoded
281
+ else {
282
+ acc[0][key] = encoded
283
+ if (type) acc[1][key.toLowerCase()] = type
225
284
  }
226
- }
227
- if (type) {
228
- // Store type with lowercase key for ao-types dictionary
229
- acc[1][key.toLowerCase()] = type
230
- }
285
+ } else if (type) acc[1][key.toLowerCase()] = type
231
286
  return acc
232
287
  },
233
288
  [{}, {}]
234
289
  )
235
290
 
236
- if (Object.keys(flattened).length === 0) return top
291
+ if (Object.keys(flattened).length === 0 && Object.keys(types).length === 0)
292
+ return top
237
293
 
238
294
  if (Object.keys(types).length > 0) {
239
- // Format as structured fields dictionary
240
295
  const aoTypeItems = Object.entries(types).map(([key, value]) => {
241
- // The Erlang side expects keys with %2f for forward slashes
242
296
  const safeKey = key
243
297
  .toLowerCase()
244
298
  .replace(
245
299
  /[^a-z0-9_\-.*\/]/g,
246
300
  c => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
247
301
  )
248
- .replace(/\//g, "%2f") // Replace forward slashes AFTER other encoding
302
+ .replace(/\//g, "%2f")
249
303
  return `${safeKey}="${value}"`
250
304
  })
251
305
  aoTypeItems.sort()
@@ -254,22 +308,15 @@ function hbEncodeLift(obj, parent = "", top = {}) {
254
308
  if (Buffer.from(aoTypes).byteLength > MAX_HEADER_LENGTH) {
255
309
  const flatK = parent ? `${parent}/ao-types` : "ao-types"
256
310
  top[flatK] = aoTypes
257
- } else {
258
- flattened["ao-types"] = aoTypes
259
- }
260
- }
261
-
262
- if (parent) {
263
- top[parent] = flattened
264
- } else {
265
- Object.assign(top, flattened)
311
+ } else flattened["ao-types"] = aoTypes
266
312
  }
267
313
 
314
+ if (parent) top[parent] = flattened
315
+ else Object.assign(top, flattened)
268
316
  return top
269
317
  }
270
318
 
271
319
  function encodePart(name, { headers = {}, body }) {
272
- // Convert headers to a plain object if it's a Headers instance
273
320
  const headerEntries =
274
321
  headers instanceof Headers
275
322
  ? Array.from(headers.entries())
@@ -290,15 +337,12 @@ function encodePart(name, { headers = {}, body }) {
290
337
 
291
338
  async function encode(obj = {}) {
292
339
  if (Object.keys(obj).length === 0) return { headers: {}, body: undefined }
293
-
294
- // Keep reference to original object for data field
295
340
  const originalObj = obj
296
341
  const flattened = hbEncodeLift(obj)
297
342
 
298
343
  const bodyKeys = []
299
344
  const headerKeys = []
300
345
 
301
- // Process all flattened keys
302
346
  await Promise.all(
303
347
  Object.keys(flattened).map(async key => {
304
348
  const value = flattened[key]
@@ -312,13 +356,23 @@ async function encode(obj = {}) {
312
356
  return
313
357
  }
314
358
 
315
- // Check if this should be a body field
316
359
  if (isBytes(value)) {
317
- // Binary data should always go to body
318
360
  bodyKeys.push(key)
361
+ const uint8Array =
362
+ value instanceof Uint8Array
363
+ ? value
364
+ : value instanceof ArrayBuffer
365
+ ? new Uint8Array(value)
366
+ : Buffer.isBuffer(value)
367
+ ? new Uint8Array(value.buffer, value.byteOffset, value.length)
368
+ : new Uint8Array(
369
+ value.buffer,
370
+ value.byteOffset,
371
+ value.byteLength
372
+ )
319
373
  flattened[key] = new Blob([
320
374
  `content-disposition: form-data;name="${key}"\r\n\r\n`,
321
- new Uint8Array(value.buffer || value),
375
+ uint8Array,
322
376
  ])
323
377
  return
324
378
  }
@@ -328,7 +382,7 @@ async function encode(obj = {}) {
328
382
  (await hasNewline(valueStr)) ||
329
383
  key.includes("/") ||
330
384
  Buffer.from(valueStr).byteLength > MAX_HEADER_LENGTH ||
331
- (isPojo(value) && valueStr === "[object Object]") // Catch unencoded objects
385
+ (isPojo(value) && valueStr === "[object Object]")
332
386
  ) {
333
387
  bodyKeys.push(key)
334
388
  flattened[key] = new Blob([
@@ -338,71 +392,65 @@ async function encode(obj = {}) {
338
392
  return
339
393
  }
340
394
 
341
- // It's a header
342
395
  headerKeys.push(key)
343
396
  })
344
397
  )
345
398
 
346
- // Build headers object with all header keys
347
399
  const headers = {}
348
400
  headerKeys.forEach(key => {
349
401
  headers[key] = flattened[key]
350
402
  })
351
403
 
352
- // Special handling for data and body fields
353
404
  if ("data" in originalObj && !bodyKeys.includes("data")) {
354
405
  bodyKeys.push("data")
355
- delete headers["data"] // Remove from headers if it was there
406
+ delete headers["data"]
356
407
  }
357
408
 
358
409
  if ("body" in originalObj && !bodyKeys.includes("body")) {
359
410
  bodyKeys.push("body")
360
- delete headers["body"] // Remove from headers if it was there
411
+ delete headers["body"]
412
+ }
413
+
414
+ if (bodyKeys.length > 0) {
415
+ headers["body-keys"] = encode_body_keys(bodyKeys)
361
416
  }
362
417
 
363
418
  let body = undefined
364
419
  let promoteToBody = true
365
420
  if (bodyKeys.length > 0) {
366
421
  if (bodyKeys.length === 1) {
367
- // If there is only one element, promote it to be the full body
368
422
  const bodyKey = bodyKeys[0]
369
423
  const originalValue = originalObj[bodyKey]
370
424
  const flattenedValue = flattened[bodyKey]
371
425
 
372
- // Only promote if it's not a complex object
373
426
  if (
374
427
  !isPojo(originalValue) ||
375
428
  (isPojo(originalValue) && typeof flattenedValue === "string")
376
429
  ) {
377
- // For objects that were encoded as structured fields, use the encoded value
378
430
  if (
379
431
  (bodyKey === "body" || bodyKey === "data") &&
380
432
  isPojo(originalValue) &&
381
433
  typeof flattenedValue === "string"
382
434
  ) {
383
435
  body = new Blob([flattenedValue])
384
- } else {
385
- body = new Blob([originalValue || flattenedValue])
386
- }
436
+ } else if (Array.isArray(originalValue)) {
437
+ const hasSymbols = originalValue.some(
438
+ item => typeof item === "symbol"
439
+ )
440
+ if (hasSymbols) {
441
+ const [type, encoded] = hbEncodeValue(originalValue)
442
+ body = new Blob([encoded || originalValue.toString()])
443
+ } else body = new Blob([originalValue.toString()])
444
+ } else body = new Blob([originalValue || flattenedValue])
387
445
  headers["inline-body-key"] = bodyKey
388
- } else {
389
- // Complex object - don't promote, create multipart
390
- promoteToBody = false
391
- }
446
+ } else promoteToBody = false
392
447
  }
393
448
 
394
449
  if (!promoteToBody || bodyKeys.length > 1) {
395
- // Multiple body fields - create multipart
396
450
  const bodyParts = await Promise.all(
397
451
  bodyKeys.map(async name => {
398
- if (flattened[name] instanceof Blob) {
399
- // The blob already has the content-disposition header
400
- return flattened[name]
401
- }
402
- // For raw values, we need to create a proper multipart part
452
+ if (flattened[name] instanceof Blob) return flattened[name]
403
453
  const value = originalObj[name] || flattened[name] || ""
404
-
405
- // Special case: if this is a structured field encoded value, use the flattened value
406
454
  if (
407
455
  name === "body" &&
408
456
  isPojo(originalObj[name]) &&
@@ -415,20 +463,50 @@ async function encode(obj = {}) {
415
463
  return partBlob
416
464
  }
417
465
 
418
- const partBlob = new Blob([
419
- `content-disposition: form-data;name="${name}"\r\n\r\n`,
420
- value,
421
- ])
466
+ let valueToEncode = value
467
+ if (Array.isArray(value)) {
468
+ const hasSymbols = value.some(item => typeof item === "symbol")
469
+ if (hasSymbols) {
470
+ const [type, encoded] = hbEncodeValue(value)
471
+ valueToEncode = encoded || value.toString()
472
+ }
473
+ } else if (isBytes(value)) valueToEncode = value
474
+ let partBlob
475
+ if (isBytes(valueToEncode)) {
476
+ const uint8Array =
477
+ valueToEncode instanceof Uint8Array
478
+ ? valueToEncode
479
+ : valueToEncode instanceof ArrayBuffer
480
+ ? new Uint8Array(valueToEncode)
481
+ : Buffer.isBuffer(valueToEncode)
482
+ ? new Uint8Array(
483
+ valueToEncode.buffer,
484
+ valueToEncode.byteOffset,
485
+ valueToEncode.length
486
+ )
487
+ : new Uint8Array(
488
+ valueToEncode.buffer,
489
+ valueToEncode.byteOffset,
490
+ valueToEncode.byteLength
491
+ )
492
+ partBlob = new Blob([
493
+ `content-disposition: form-data;name="${name}"\r\n\r\n`,
494
+ uint8Array,
495
+ ])
496
+ } else {
497
+ partBlob = new Blob([
498
+ `content-disposition: form-data;name="${name}"\r\n\r\n`,
499
+ valueToEncode,
500
+ ])
501
+ }
422
502
  return partBlob
423
503
  })
424
504
  )
425
505
 
426
- // Calculate boundary from the content
427
506
  const allPartsBuffer = await new Blob(bodyParts).arrayBuffer()
428
507
  const hash = await sha256(allPartsBuffer)
429
508
  const boundary = base64url.encode(Buffer.from(hash))
430
509
 
431
- // Build the multipart body with proper boundaries
432
510
  const finalParts = []
433
511
  for (const part of bodyParts) {
434
512
  finalParts.push(`--${boundary}\r\n`)
@@ -445,8 +523,6 @@ async function encode(obj = {}) {
445
523
  const finalContent = await body.arrayBuffer()
446
524
  const contentDigest = await sha256(finalContent)
447
525
  const base64 = base64url.toBase64(base64url.encode(contentDigest))
448
-
449
- // Use lowercase to match what's in the other headers
450
526
  headers["content-digest"] = `sha-256=:${base64}:`
451
527
  headers["content-length"] = String(finalContent.byteLength)
452
528
  }
@@ -455,9 +531,6 @@ async function encode(obj = {}) {
455
531
  return { headers, body }
456
532
  }
457
533
 
458
- /**
459
- * Create HTTP signer wrapper
460
- */
461
534
  const toHttpSigner = signer => {
462
535
  const params = ["alg", "keyid"].sort()
463
536
 
@@ -515,31 +588,17 @@ const toHttpSigner = signer => {
515
588
  httpSigName(result.address)
516
589
  )
517
590
 
518
- // Only lowercase the signature headers
519
591
  const finalHeaders = {}
520
592
  for (const [key, value] of Object.entries(signedHeaders)) {
521
593
  if (key === "Signature" || key === "Signature-Input") {
522
594
  finalHeaders[key.toLowerCase()] = value
523
- } else {
524
- finalHeaders[key] = value
525
- }
595
+ } else finalHeaders[key] = value
526
596
  }
527
597
 
528
- return {
529
- ...request,
530
- headers: finalHeaders,
531
- }
598
+ return { ...request, headers: finalHeaders }
532
599
  }
533
600
  }
534
601
 
535
- /**
536
- * Create the main request function that creates signed messages locally
537
- *
538
- * @param {Object} config - Configuration object
539
- * @param {Function} config.signer - Signer function
540
- * @param {string} [config.HB_URL='http://relay.ao-hb.xyz'] - Base URL
541
- * @returns {Function} Request function that takes tags and returns signed message
542
- */
543
602
  export function createRequest(config) {
544
603
  const { signer, url = "http://localhost:10001" } = config
545
604
 
@@ -549,20 +608,20 @@ export function createRequest(config) {
549
608
 
550
609
  return async function request(fields) {
551
610
  const { path = "/relay/process", method = "POST", ...restFields } = fields
552
-
553
- // Add default AO fields
554
611
  const aoFields = { ...restFields }
612
+ const rootKeys = Object.keys(aoFields)
613
+ const binaryKeys = rootKeys.filter(key => isBytes(aoFields[key]))
555
614
 
556
- // Use the HyperBEAM encode function
557
- const encoded = await encode(aoFields)
615
+ if (binaryKeys.length > 1 && !aoFields.body && !aoFields.data) {
616
+ aoFields.body = "1984"
617
+ }
558
618
 
559
- // If no encoding needed, create minimal structure
619
+ const encoded = await encode(aoFields)
560
620
  const headersObj = encoded ? encoded.headers : {}
561
621
  const body = encoded ? encoded.body : undefined
562
622
 
563
623
  const _url = joinUrl({ url, path })
564
624
 
565
- // Add Content-Length if body exists
566
625
  if (body && !headersObj["content-length"]) {
567
626
  const bodySize = body.size || body.byteLength || 0
568
627
  if (bodySize > 0) {
@@ -570,87 +629,51 @@ export function createRequest(config) {
570
629
  }
571
630
  }
572
631
 
573
- // Create lowercase headers for signing
574
632
  const lowercaseHeaders = {}
575
633
  for (const [key, value] of Object.entries(headersObj)) {
576
634
  lowercaseHeaders[key.toLowerCase()] = value
577
635
  }
578
636
 
579
- // Get all header keys for signing (lowercase)
580
- const signingFields = Object.keys(lowercaseHeaders)
637
+ const signingFields = Object.keys(lowercaseHeaders).filter(
638
+ key => key !== "body-keys"
639
+ )
581
640
 
582
- // If there are no fields to sign, add at least the content-length
583
641
  if (signingFields.length === 0 && !body) {
584
642
  lowercaseHeaders["content-length"] = "0"
585
643
  signingFields.push("content-length")
586
644
  }
587
645
 
588
- // Sign the request with lowercase headers
589
646
  const signedRequest = await toHttpSigner(signer)({
590
647
  request: { url: _url, method, headers: lowercaseHeaders },
591
648
  fields: signingFields,
592
649
  })
593
650
 
594
- // Build final headers: use original casing for all headers except signature headers
595
651
  const finalHeaders = {}
596
652
 
597
- // First, add all original headers with their original casing
598
653
  for (const [key, value] of Object.entries(headersObj)) {
599
654
  finalHeaders[key] = value
600
655
  }
601
656
 
602
- // Then add the signature headers (which should be lowercase)
603
657
  finalHeaders["signature"] = signedRequest.headers["signature"]
604
658
  finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
605
659
 
606
- // Return the signed message
607
- const result = {
608
- url: _url,
609
- method,
610
- headers: finalHeaders,
660
+ if (headersObj["body-keys"]) {
661
+ finalHeaders["body-keys"] = headersObj["body-keys"]
611
662
  }
612
663
 
613
- // Only add body if it exists
614
- if (body) {
615
- result.body = body
616
- }
664
+ const result = { url: _url, method, headers: finalHeaders }
665
+
666
+ if (body) result.body = body
617
667
 
618
668
  return result
619
669
  }
620
670
  }
621
- /**
622
- * Utility function to extract the message ID from a signed message
623
- * Based on the original code's hash calculation
624
- */
625
- async function getMessageId(signedMessage) {
626
- // Extract signature from the Signature header
627
- const signatureHeader =
628
- signedMessage.headers.Signature || signedMessage.headers.signature
629
- const match = signatureHeader.match(/Signature:\s*'http-sig-[^:]+:([^']+)'/)
630
- const signature = match ? match[1] : null
631
-
632
- if (!signature) {
633
- throw new Error("Could not extract signature from headers")
634
- }
635
-
636
- // Hash the signature to get message ID
637
- const encoder = new TextEncoder()
638
- const data = encoder.encode(signature)
639
- const hashBuffer = await crypto.subtle.digest("SHA-256", data)
640
- const hashArray = Array.from(new Uint8Array(hashBuffer))
641
- const hashBase64 = btoa(String.fromCharCode(...hashArray))
642
-
643
- return hashBase64
644
- }
645
-
646
671
  export async function send(signedMsg, fetchImpl = fetch) {
647
672
  const fetchOptions = {
648
673
  method: signedMsg.method,
649
674
  headers: signedMsg.headers,
650
675
  redirect: "follow",
651
676
  }
652
-
653
- // Only add body if it exists and method supports it
654
677
  if (
655
678
  signedMsg.body !== undefined &&
656
679
  signedMsg.method !== "GET" &&
@@ -664,7 +687,6 @@ export async function send(signedMsg, fetchImpl = fetch) {
664
687
  throw new Error(`${response.status}: ${await response.text()}`)
665
688
  }
666
689
 
667
- // Convert Headers object to plain object
668
690
  let headers = {}
669
691
  if (response.headers && typeof response.headers.forEach === "function") {
670
692
  response.headers.forEach((v, k) => (headers[k] = v))
@@ -677,238 +699,3 @@ export async function send(signedMsg, fetchImpl = fetch) {
677
699
  status: response.status,
678
700
  }
679
701
  }
680
-
681
- /**
682
- * Convert JWK modulus (n) to PEM format public key
683
- * @param {Buffer} nBuffer - The modulus buffer
684
- * @returns {string} PEM formatted public key
685
- */
686
- function jwkModulusToPem(nBuffer) {
687
- // RSA public key with standard exponent
688
- const rsaPublicKey = crypto.createPublicKey({
689
- key: {
690
- kty: "RSA",
691
- n: base64url.encode(nBuffer),
692
- e: "AQAB", // Standard exponent 65537
693
- },
694
- format: "jwk",
695
- })
696
-
697
- return rsaPublicKey.export({ type: "spki", format: "pem" })
698
- }
699
-
700
- /**
701
- * Extract signature name from headers
702
- * @param {Object} headers - Request headers
703
- * @returns {string|null} Signature name or null
704
- */
705
- function extractSignatureName(headers) {
706
- const signatureHeader = headers["signature"] || headers["Signature"]
707
- if (!signatureHeader) return null
708
-
709
- // Extract signature name (e.g., "http-sig-xxxxxxxx")
710
- // Handle both "name:" and "name=" formats
711
- const match = signatureHeader.match(/^([^:=]+)[:=]/)
712
- return match ? match[1] : null
713
- }
714
-
715
- /**
716
- * Extract public key from signature-input header
717
- * @param {Object} headers - Request headers
718
- * @param {string} [signatureName] - Optional signature name to look for
719
- * @returns {Buffer|null} Public key buffer or null
720
- */
721
- export function extractPublicKeyFromHeaders(headers, signatureName) {
722
- const signatureInput =
723
- headers["signature-input"] || headers["Signature-Input"]
724
- if (!signatureInput) return null
725
-
726
- // If we have a signature name, look for its specific keyid
727
- let keyidMatch
728
- if (signatureName) {
729
- // The signature-input format is: signatureName=(...);alg="...";keyid="..."
730
- // We need to match after the signature name
731
- const signatureSection = signatureInput.substring(
732
- signatureInput.indexOf(signatureName)
733
- )
734
- keyidMatch = signatureSection.match(/keyid="([^"]+)"/)
735
- } else {
736
- // General keyid match
737
- keyidMatch = signatureInput.match(/keyid="([^"]+)"/)
738
- }
739
-
740
- if (!keyidMatch) return null
741
-
742
- try {
743
- return base64url.toBuffer(keyidMatch[1])
744
- } catch (error) {
745
- return null
746
- }
747
- }
748
-
749
- /**
750
- * Verify an HTTP signed message using http-message-signatures
751
- *
752
- * @param {Object} signedMessage - The signed message to verify
753
- * @param {string} signedMessage.url - Request URL
754
- * @param {string} signedMessage.method - HTTP method
755
- * @param {Object} signedMessage.headers - Headers including signature
756
- * @param {string} [signedMessage.body] - Request body
757
- * @param {string|Buffer} [publicKey] - Optional public key (if not provided, extracts from keyid)
758
- * @returns {Object} Verification result
759
- */
760
- export async function verify(signedMessage, publicKey) {
761
- try {
762
- const { url, method, headers, body } = signedMessage
763
-
764
- // Determine which public key to use
765
- let keyLookup
766
-
767
- if (publicKey) {
768
- // Use provided public key
769
- const pem =
770
- typeof publicKey === "string" ? publicKey : jwkModulusToPem(publicKey)
771
-
772
- keyLookup = async keyId => {
773
- return {
774
- id: keyId,
775
- algs: ["rsa-pss-sha512", "rsa-pss-sha256", "rsa-v1_5-sha256"],
776
- verify: async (data, signature, parameters) => {
777
- const verifier = crypto.createVerify(
778
- `RSA-SHA${parameters.alg.includes("512") ? "512" : "256"}`
779
- )
780
- verifier.update(data)
781
-
782
- if (parameters.alg.startsWith("rsa-pss")) {
783
- return verifier.verify(
784
- {
785
- key: pem,
786
- padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
787
- saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
788
- },
789
- signature
790
- )
791
- } else {
792
- return verifier.verify(pem, signature)
793
- }
794
- },
795
- }
796
- }
797
- } else {
798
- // Extract public key from keyid
799
- const signatureName = extractSignatureName(headers)
800
- const extractedKey = extractPublicKeyFromHeaders(headers, signatureName)
801
- if (!extractedKey) {
802
- return {
803
- valid: false,
804
- error: "No public key provided and none found in signature",
805
- }
806
- }
807
-
808
- const pem = jwkModulusToPem(extractedKey)
809
-
810
- keyLookup = async keyId => {
811
- // The library might pass the keyId in different formats, so be flexible
812
- return {
813
- id: keyId,
814
- algs: ["rsa-pss-sha512", "rsa-pss-sha256", "rsa-v1_5-sha256"],
815
- verify: async (data, signature, parameters) => {
816
- try {
817
- const verifier = crypto.createVerify(
818
- `RSA-SHA${parameters.alg.includes("512") ? "512" : "256"}`
819
- )
820
- verifier.update(data)
821
-
822
- let verified
823
- if (parameters.alg.startsWith("rsa-pss")) {
824
- verified = verifier.verify(
825
- {
826
- key: pem,
827
- padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
828
- saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
829
- },
830
- signature
831
- )
832
- } else {
833
- verified = verifier.verify(pem, signature)
834
- }
835
-
836
- return verified
837
- } catch (error) {
838
- console.error("Verification error:", error)
839
- return false
840
- }
841
- },
842
- }
843
- }
844
- }
845
-
846
- // Create request object for verification
847
- const request = {
848
- method,
849
- url,
850
- headers: { ...headers },
851
- }
852
-
853
- // Extract additional info from headers
854
- const signatureName = extractSignatureName(headers)
855
- const extractedPublicKey = extractPublicKeyFromHeaders(
856
- headers,
857
- signatureName
858
- )
859
-
860
- // Extract algorithm from signature-input
861
- const signatureInputHeader =
862
- headers["signature-input"] || headers["Signature-Input"]
863
- const algMatch = signatureInputHeader?.match(/alg="([^"]+)"/)
864
- const algorithm = algMatch ? algMatch[1] : undefined
865
-
866
- // Verify using the library
867
- let verified = false
868
- let verificationError = null
869
-
870
- try {
871
- const verificationResult = await verifyMessage(
872
- {
873
- keyLookup,
874
- requiredFields: [], // Don't require specific fields
875
- },
876
- request
877
- )
878
- // If we get here without throwing, verification succeeded
879
- verified = true
880
- } catch (verifyError) {
881
- // Verification failed
882
- verificationError = verifyError.message
883
- verified = false
884
- }
885
-
886
- return {
887
- valid: true, // The signature format is valid
888
- verified, // Whether the cryptographic verification passed
889
- signatureName,
890
- keyId: extractedPublicKey
891
- ? base64url.encode(extractedPublicKey)
892
- : undefined,
893
- algorithm,
894
- publicKeyFromHeader: extractedPublicKey,
895
- ...(verificationError && { error: verificationError }),
896
- }
897
- } catch (error) {
898
- return {
899
- valid: false,
900
- error: error.message,
901
- }
902
- }
903
- }
904
-
905
- /**
906
- * Extract public key from a signed message
907
- *
908
- * @param {Object} signedMessage - The signed message
909
- * @returns {Buffer|null} The public key buffer or null
910
- */
911
- function extractPublicKeyFromMessage(signedMessage) {
912
- const signatureName = extractSignatureName(signedMessage.headers)
913
- return extractPublicKeyFromHeaders(signedMessage.headers, signatureName)
914
- }