hbsig 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cjs/structured.js CHANGED
@@ -77,7 +77,7 @@ function to(tabm) {
77
77
  // Build result with empty values first
78
78
  var result = {};
79
79
 
80
- // Add empty values based on their types
80
+ // Add empty values based on their types (these may be overwritten if data exists)
81
81
  for (var _i = 0, _Object$entries = Object.entries(types); _i < _Object$entries.length; _i++) {
82
82
  var _Object$entries$_i = _slicedToArray(_Object$entries[_i], 2),
83
83
  key = _Object$entries$_i[0],
@@ -111,10 +111,19 @@ function to(tabm) {
111
111
  result[rawKey] = value;
112
112
  }
113
113
  } else if (_typeof(value) === "object" && value !== null && !Array.isArray(value)) {
114
+ // Check if the child object itself indicates it's a list via .="list" in its ao-types
115
+ var childAoTypes = value["ao-types"] || "";
116
+ var childTypes = parseAoTypes(childAoTypes);
117
+ var isChildList = childTypes["."] === "list";
118
+
114
119
  // Recursively decode child TABM
115
120
  var childDecoded = to(value);
116
- var _type2 = types[normalizedKey];
117
- if (_type2 === "list") {
121
+
122
+ // Only convert numbered map to array if the child object itself
123
+ // declares it's a list via .="list" in its ao-types.
124
+ // This preserves original keys when the parent declares the type
125
+ // but the child doesn't have the list marker.
126
+ if (isChildList) {
118
127
  // Convert numbered map back to ordered list
119
128
  result[rawKey] = messageToOrderedList(childDecoded);
120
129
  } else {
@@ -214,11 +223,22 @@ function decodeValue(type, value) {
214
223
  return parseInt(parseStructuredItem(value), 10);
215
224
  case "float":
216
225
  return parseFloat(value);
226
+ case "boolean":
227
+ // SF boolean format: ?1 = true, ?0 = false
228
+ // Convert to native boolean, will be encoded as "atom" type
229
+ if (value === "?1") return true;
230
+ if (value === "?0") return false;
231
+ // Fallback for other formats
232
+ return value === "true" || value === "1";
217
233
  case "atom":
218
234
  var atomItem = parseStructuredItem(value);
219
- return atomItem.replace(/^"|"$/g, "");
220
- // Remove quotes
221
-
235
+ var atomName = atomItem.replace(/^"|"$/g, ""); // Remove quotes
236
+ // Convert to Symbol to preserve atom type through round-trip
237
+ // Special cases for common atoms that JS has native types for
238
+ if (atomName === "true") return true;
239
+ if (atomName === "false") return false;
240
+ if (atomName === "null") return null;
241
+ return Symbol["for"](atomName);
222
242
  case "list":
223
243
  return parseStructuredList(value).map(function (item) {
224
244
  if (typeof item === "string" && item.startsWith("(ao-type-")) {
@@ -245,7 +265,8 @@ function decodeValue(type, value) {
245
265
  * @returns {*} - Parsed value
246
266
  */
247
267
  function parseStructuredItem(value) {
248
- // This is a simplified parser - you'd want to use a proper structured fields parser
268
+ // Handle non-string values (e.g., numbers from HyperBEAM responses)
269
+ if (typeof value !== "string") return String(value);
249
270
  if (value.startsWith('"') && value.endsWith('"')) {
250
271
  return value.slice(1, -1); // Remove quotes
251
272
  }
@@ -258,10 +279,13 @@ function parseStructuredItem(value) {
258
279
  * @returns {Array} - Parsed list
259
280
  */
260
281
  function parseStructuredList(value) {
261
- // This is a simplified parser - you'd want to use a proper structured fields parser
282
+ if (typeof value !== "string") return [value];
262
283
  return value.split(", ").map(function (item) {
263
284
  if (item.startsWith('"') && item.endsWith('"')) {
264
- return item.slice(1, -1); // Remove quotes
285
+ // Remove quotes and unescape SF string escapes
286
+ // In SF strings: \" = " and \\ = \
287
+ return item.slice(1, -1).replace(/\\"/g, '"') // \" -> "
288
+ .replace(/\\\\/g, '\\'); // \\ -> \
265
289
  }
266
290
  return item;
267
291
  });
@@ -273,23 +297,50 @@ function parseStructuredList(value) {
273
297
  * @returns {object} - TABM
274
298
  */
275
299
  function from(msg) {
276
- // Handle non-map values
277
- if (msg instanceof Buffer || _typeof(msg) !== "object" || msg === null || Array.isArray(msg)) {
300
+ // Handle binary input - passthrough
301
+ if (msg instanceof Buffer || msg instanceof Uint8Array) {
302
+ return msg;
303
+ }
304
+
305
+ // Handle non-object values - passthrough
306
+ if (_typeof(msg) !== "object" || msg === null) {
278
307
  return msg;
279
308
  }
280
309
 
281
- // Normalize keys first
282
- var normalizedMap = {};
310
+ // Handle arrays - convert to numbered map with .="list" in ao-types
311
+ // Mirrors Erlang: from(List, Req, Opts) when is_list(List)
312
+ if (Array.isArray(msg)) {
313
+ // Convert to numbered map (1-based indexing like Erlang)
314
+ var numberedMap = {};
315
+ msg.forEach(function (item, idx) {
316
+ numberedMap[(idx + 1).toString()] = item;
317
+ });
318
+
319
+ // Recursively process the numbered map
320
+ var _result = from(numberedMap);
321
+
322
+ // Add .="list" to ao-types to indicate this message is a list
323
+ var existingAoTypes = _result["ao-types"] || "";
324
+ if (existingAoTypes) {
325
+ _result["ao-types"] = '.="list", ' + existingAoTypes;
326
+ } else {
327
+ _result["ao-types"] = '.="list"';
328
+ }
329
+ return _result;
330
+ }
331
+
332
+ // Process keys - preserve original case to match Erlang behavior
333
+ // HTTP headers are case-insensitive but JSON/map keys preserve case
334
+ var keysMap = {};
283
335
  for (var _i3 = 0, _Object$entries3 = Object.entries(msg); _i3 < _Object$entries3.length; _i3++) {
284
336
  var _Object$entries3$_i = _slicedToArray(_Object$entries3[_i3], 2),
285
337
  key = _Object$entries3$_i[0],
286
338
  value = _Object$entries3$_i[1];
287
- var normKey = key.toLowerCase();
288
- normalizedMap[normKey] = value;
339
+ keysMap[key] = value;
289
340
  }
290
341
 
291
- // Get sorted keys (normalized)
292
- var sortedKeys = Object.keys(normalizedMap).sort();
342
+ // Get sorted keys (preserving case)
343
+ var sortedKeys = Object.keys(keysMap).sort();
293
344
  var types = [];
294
345
  var values = [];
295
346
 
@@ -297,77 +348,63 @@ function from(msg) {
297
348
  var _iterator2 = _createForOfIteratorHelper(sortedKeys),
298
349
  _step2;
299
350
  try {
300
- var _loop = function _loop() {
301
- var normKey = _step2.value;
302
- var value = normalizedMap[normKey];
303
-
304
- // Handle empty values
305
- if (value === "" || value instanceof Buffer && value.length === 0) {
306
- types.push([normKey, "empty-binary"]);
307
- return 0; // continue
308
- }
309
- if (Array.isArray(value) && value.length === 0) {
310
- types.push([normKey, "empty-list"]);
311
- return 0; // continue
312
- }
313
- if (_typeof(value) === "object" && value !== null && !Array.isArray(value) && !(value instanceof Buffer) && Object.keys(value).length === 0) {
314
- types.push([normKey, "empty-message"]);
315
- return 0; // continue
316
- }
351
+ for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
352
+ var _key = _step2.value;
353
+ var _value = keysMap[_key];
354
+
355
+ // Handle empty binaries/strings - just include as-is, no type annotation
356
+ // (Erlang doesn't add empty-binary type, it just keeps the empty binary)
357
+ if (_value === "" || _value instanceof Buffer && _value.length === 0) {
358
+ values.push([_key, _value]);
359
+ continue;
360
+ }
317
361
 
318
- // Handle binary/string values
319
- if (value instanceof Buffer || value instanceof Uint8Array) {
320
- values.push([normKey, value]);
321
- return 0; // continue
322
- }
323
- if (typeof value === "string") {
324
- values.push([normKey, value]);
325
- return 0; // continue
326
- }
362
+ // Empty arrays - convert to numbered map with .="list" in ao-types
363
+ // (Erlang doesn't add empty-list type, just the list marker)
364
+ if (Array.isArray(_value) && _value.length === 0) {
365
+ values.push([_key, from(_value)]);
366
+ continue;
367
+ }
327
368
 
328
- // Handle nested maps
329
- if (_typeof(value) === "object" && !Array.isArray(value) && value !== null) {
330
- values.push([normKey, from(value)]);
331
- return 0; // continue
332
- }
369
+ // Empty objects - just include as-is, no type annotation
370
+ // (Erlang doesn't add empty-message type, it just keeps the empty map)
371
+ if (_typeof(_value) === "object" && _value !== null && !Array.isArray(_value) && !(_value instanceof Buffer) && Object.keys(_value).length === 0) {
372
+ values.push([_key, _value]);
373
+ continue;
374
+ }
333
375
 
334
- // Handle arrays
335
- if (Array.isArray(value) && value.length > 0) {
336
- if (shouldConvertToNumberedMap(value)) {
337
- // Convert to numbered map (1-based indexing)
338
- var numberedMap = {};
339
- value.forEach(function (item, idx) {
340
- numberedMap[(idx + 1).toString()] = item;
341
- });
342
- types.push([normKey, "list"]);
343
- values.push([normKey, from(numberedMap)]);
344
- } else {
345
- // Encode as list string
346
- var _encodeValue = encodeValue(value),
347
- _encodeValue2 = _slicedToArray(_encodeValue, 2),
348
- type = _encodeValue2[0],
349
- encoded = _encodeValue2[1];
350
- types.push([normKey, type]);
351
- values.push([normKey, encoded]);
352
- }
353
- return 0; // continue
354
- }
376
+ // Handle binary/string values
377
+ if (_value instanceof Buffer || _value instanceof Uint8Array) {
378
+ values.push([_key, _value]);
379
+ continue;
380
+ }
381
+ if (typeof _value === "string") {
382
+ values.push([_key, _value]);
383
+ continue;
384
+ }
355
385
 
356
- // Handle typed values (need encoding)
357
- if (_typeof(value) === "symbol" || typeof value === "number" || Array.isArray(value) || typeof value === "boolean" || value === null) {
358
- var _encodeValue3 = encodeValue(value),
359
- _encodeValue4 = _slicedToArray(_encodeValue3, 2),
360
- _type3 = _encodeValue4[0],
361
- _encoded = _encodeValue4[1];
362
- types.push([normKey, _type3]);
363
- values.push([normKey, _encoded]);
364
- return 0; // continue
365
- }
366
- },
367
- _ret;
368
- for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
369
- _ret = _loop();
370
- if (_ret === 0) continue;
386
+ // Handle nested maps
387
+ if (_typeof(_value) === "object" && !Array.isArray(_value) && _value !== null) {
388
+ values.push([_key, from(_value)]);
389
+ continue;
390
+ }
391
+
392
+ // Handle arrays - from() converts to numbered map with .="list" in ao-types
393
+ if (Array.isArray(_value) && _value.length > 0) {
394
+ values.push([_key, from(_value)]);
395
+ continue;
396
+ }
397
+
398
+ // Handle typed values (need encoding)
399
+ if (_typeof(_value) === "symbol" || typeof _value === "number" || typeof _value === "boolean" || _value === null) {
400
+ var _encodeValue = encodeValue(_value),
401
+ _encodeValue2 = _slicedToArray(_encodeValue, 2),
402
+ type = _encodeValue2[0],
403
+ encoded = _encodeValue2[1];
404
+ types.push([_key, type]);
405
+ values.push([_key, encoded]);
406
+ continue;
407
+ }
371
408
  }
372
409
 
373
410
  // Build result
@@ -398,55 +435,13 @@ function from(msg) {
398
435
  return result;
399
436
  }
400
437
 
401
- /**
402
- * Check if an array should be converted to numbered map
403
- * Rules based on Erlang behavior:
404
- * 1. Contains any objects/maps → convert
405
- * 2. Contains empty arrays (but NOT empty buffers) → convert
406
- * 3. All items are arrays (array of arrays) → convert
407
- * 4. Otherwise → encode as string
408
- */
409
- function shouldConvertToNumberedMap(arr) {
410
- var allArrays = true;
411
- var hasObjects = false;
412
- var hasEmptyArrays = false;
413
- var _iterator3 = _createForOfIteratorHelper(arr),
414
- _step3;
415
- try {
416
- for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
417
- var item = _step3.value;
418
- // Check for objects (not arrays or buffers)
419
- if (_typeof(item) === "object" && item !== null && !Array.isArray(item) && !Buffer.isBuffer(item)) {
420
- hasObjects = true;
421
- }
422
- // Check for empty arrays only (NOT empty buffers)
423
- else if (Array.isArray(item) && item.length === 0) {
424
- hasEmptyArrays = true;
425
- }
426
- // Track if all items are arrays
427
- else if (!Array.isArray(item)) {
428
- allArrays = false;
429
- }
430
- }
431
-
432
- // Convert if: has objects, has empty arrays, or all items are non-empty arrays
433
- } catch (err) {
434
- _iterator3.e(err);
435
- } finally {
436
- _iterator3.f();
437
- }
438
- return hasObjects || hasEmptyArrays || allArrays && arr.length > 0 && arr.every(function (item) {
439
- return Array.isArray(item);
440
- });
441
- }
442
-
443
438
  /**
444
439
  * Encode a value with its type
445
440
  */
446
441
  function encodeValue(value) {
447
- // Null (as atom)
442
+ // Null (as atom) - use token format (unquoted)
448
443
  if (value === null) {
449
- return ["atom", '"null"'];
444
+ return ["atom", "null"];
450
445
  }
451
446
 
452
447
  // Integer
@@ -456,44 +451,43 @@ function encodeValue(value) {
456
451
 
457
452
  // Float
458
453
  if (typeof value === "number") {
459
- // Format like Erlang with scientific notation
454
+ // Format like Erlang's float_to_binary - scientific notation with full precision
455
+ // Erlang's float_to_binary/1 uses ~20 decimal digits and keeps trailing zeros
460
456
  var str = value.toExponential(20);
461
- // Remove trailing zeros but keep at least one
462
- str = str.replace(/(\.\d*?)0+e/, "$1e").replace(/\.e/, ".0e");
463
- // Ensure 2-digit exponent
457
+ // Ensure 2-digit exponent with sign
464
458
  str = str.replace(/e([+-])(\d)$/, "e$10$2");
465
459
  return ["float", str];
466
460
  }
467
461
 
468
- // Boolean (as atom)
462
+ // Boolean (as atom) - use token format (unquoted)
469
463
  if (typeof value === "boolean") {
470
- return ["atom", "\"".concat(value, "\"")];
464
+ return ["atom", value.toString()];
471
465
  }
472
466
 
473
- // Symbol (as atom)
467
+ // Symbol (as atom) - use token format (unquoted)
474
468
  if (_typeof(value) === "symbol") {
475
469
  var name = Symbol.keyFor(value) || value.description || "";
476
- return ["atom", "\"".concat(name, "\"")];
470
+ return ["atom", name];
477
471
  }
478
472
 
479
473
  // List
480
474
  if (Array.isArray(value)) {
481
475
  var parts = [];
482
- var _iterator4 = _createForOfIteratorHelper(value),
483
- _step4;
476
+ var _iterator3 = _createForOfIteratorHelper(value),
477
+ _step3;
484
478
  try {
485
- for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
486
- var item = _step4.value;
479
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
480
+ var item = _step3.value;
487
481
  if (item instanceof Buffer) {
488
482
  // Empty buffer => empty string
489
483
  parts.push(item.length === 0 ? '""' : "\"".concat(item.toString(), "\""));
490
484
  } else if (typeof item === "string") {
491
485
  parts.push("\"".concat(item, "\""));
492
486
  } else {
493
- var _encodeValue5 = encodeValue(item),
494
- _encodeValue6 = _slicedToArray(_encodeValue5, 2),
495
- itemType = _encodeValue6[0],
496
- itemEncoded = _encodeValue6[1];
487
+ var _encodeValue3 = encodeValue(item),
488
+ _encodeValue4 = _slicedToArray(_encodeValue3, 2),
489
+ itemType = _encodeValue4[0],
490
+ itemEncoded = _encodeValue4[1];
497
491
  if (itemType === "list") {
498
492
  // Escape nested list quotes
499
493
  var escaped = itemEncoded.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -508,9 +502,9 @@ function encodeValue(value) {
508
502
  }
509
503
  }
510
504
  } catch (err) {
511
- _iterator4.e(err);
505
+ _iterator3.e(err);
512
506
  } finally {
513
- _iterator4.f();
507
+ _iterator3.f();
514
508
  }
515
509
  return ["list", parts.join(", ")];
516
510
  }
package/cjs/test.js CHANGED
@@ -3,25 +3,10 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- Object.defineProperty(exports, "acc", {
7
- enumerable: true,
8
- get: function get() {
9
- return _accounts.acc;
10
- }
11
- });
12
6
  Object.defineProperty(exports, "toAddr", {
13
7
  enumerable: true,
14
8
  get: function get() {
15
9
  return _utils.toAddr;
16
10
  }
17
11
  });
18
- Object.defineProperty(exports, "wait", {
19
- enumerable: true,
20
- get: function get() {
21
- return _utils.wait;
22
- }
23
- });
24
- var _fs = require("fs");
25
- var _path = require("path");
26
- var _accounts = require("./accounts.js");
27
12
  var _utils = require("./utils.js");
package/esm/commit.js CHANGED
@@ -1,7 +1,42 @@
1
- import { id, base, hashpath, rsaid, hmacid } from "./id.js"
1
+ import { id, base, hashpath, rsaid } from "./id.js"
2
2
  import { toAddr } from "./utils.js"
3
3
  import { extractPubKey } from "./signer-utils.js"
4
4
  import { verify } from "./signer-utils.js"
5
+ import crypto from "crypto"
6
+
7
+ // Helper to compute SHA-256 content-digest in RFC 9530 format
8
+ const computeContentDigest = (body) => {
9
+ let bodyBuffer
10
+ if (Buffer.isBuffer(body)) {
11
+ bodyBuffer = body
12
+ } else if (body instanceof Blob) {
13
+ return null // Can't compute synchronously for Blob
14
+ } else if (typeof body === "string") {
15
+ bodyBuffer = Buffer.from(body, "binary")
16
+ } else {
17
+ bodyBuffer = Buffer.from(String(body), "binary")
18
+ }
19
+ const hash = crypto.createHash("sha256").update(bodyBuffer).digest("base64")
20
+ return `sha-256=:${hash}:`
21
+ }
22
+
23
+ // Helper to build ao-types string from an object
24
+ const buildAoTypes = (obj) => {
25
+ const types = []
26
+ for (const [key, value] of Object.entries(obj)) {
27
+ if (typeof value === "number") {
28
+ types.push(`${key}="${Number.isInteger(value) ? "integer" : "float"}"`)
29
+ } else if (typeof value === "boolean") {
30
+ types.push(`${key}="atom"`)
31
+ } else if (value === null) {
32
+ types.push(`${key}="atom"`)
33
+ } else if (typeof value === "symbol") {
34
+ // Symbols are Erlang atoms
35
+ types.push(`${key}="atom"`)
36
+ }
37
+ }
38
+ return types.length > 0 ? types.join(", ") : null
39
+ }
5
40
 
6
41
  // todo: handle @
7
42
  export const commit = async (obj, opts) => {
@@ -12,19 +47,63 @@ export const commit = async (obj, opts) => {
12
47
 
13
48
  let body = {}
14
49
 
15
- // Check for inline-body-key
16
- const inlineBodyKey = msg.headers["inline-body-key"]
50
+ // Check for inline-body-key (indicates a field was moved to HTTP body during encoding)
51
+ const inlineBodyKey = msg.headers["inline-body-key"] || msg.headers["ao-body-key"]
52
+
53
+ // Body field names that HyperBEAM's inline_key() recognizes natively.
54
+ // For these, normalize_for_encoding() re-derives ao-body-key automatically.
55
+ // For custom names (e.g., "json"), we must keep ao-body-key in committed list
56
+ // so HyperBEAM knows which field to inline during verification.
57
+ const NATIVE_BODY_KEYS = new Set(["body", "data"])
58
+ const isNativeBodyKey = !inlineBodyKey || NATIVE_BODY_KEYS.has(inlineBodyKey)
59
+
60
+ // Build body from committed components.
61
+ // IMPORTANT: Use original typed values from obj (preserves integers, booleans, etc.)
62
+ // instead of string values from headers. This is critical because:
63
+ // 1. HTTP headers are always strings (e.g., quantity: 100 → "100")
64
+ // 2. We include ao-types to tell HyperBEAM the original types
65
+ // 3. HyperBEAM applies ao-types BEFORE signature verification
66
+ // 4. If we send strings, HyperBEAM converts to integers, breaking signature
67
+ // 5. If we send original integers, HyperBEAM re-encodes them as strings for verification
68
+ //
69
+ // Always skip content-digest and inline-body-key (transport artifacts re-derived by HyperBEAM).
70
+ // Skip ao-body-key only for native body keys (HyperBEAM re-derives it).
71
+ // Keep ao-body-key for custom body keys (HyperBEAM needs it to find the body field).
72
+
73
+ // Create case-insensitive lookup maps
74
+ const headerLookup = new Map()
75
+ for (const [k, v] of Object.entries(msg.headers)) {
76
+ headerLookup.set(k.toLowerCase(), v)
77
+ }
78
+ // Case-insensitive lookup for original object values (preserves types)
79
+ const objLookup = new Map()
80
+ for (const [k, v] of Object.entries(obj)) {
81
+ objLookup.set(k.toLowerCase(), v)
82
+ }
17
83
 
18
- // Build body from components
19
84
  for (const v of components) {
20
85
  const key = v === "@path" ? "path" : v
21
- body[key] = msg.headers[key]
86
+ if (key === "content-length") continue
87
+ if (key === "content-digest") continue
88
+ if (key === "inline-body-key") continue
89
+ if (isNativeBodyKey && key === "ao-body-key") continue
90
+
91
+ // Prefer original value from input object (preserves integer/boolean/etc. types)
92
+ // Fall back to header string value if not found in original object
93
+ const originalValue = objLookup.get(key.toLowerCase())
94
+ if (originalValue !== undefined) {
95
+ body[key] = originalValue
96
+ } else {
97
+ const headerValue = headerLookup.get(key)
98
+ if (headerValue !== undefined) {
99
+ body[key] = headerValue
100
+ }
101
+ }
22
102
  }
23
103
 
24
- // Handle body resolution
104
+ // Handle body resolution - restore the inlined field to its AO-Core name
105
+ let bodyContent = null
25
106
  if (msg.body) {
26
- let bodyContent
27
-
28
107
  if (msg.body instanceof Blob) {
29
108
  const arrayBuffer = await msg.body.arrayBuffer()
30
109
  bodyContent = Buffer.from(arrayBuffer)
@@ -32,31 +111,107 @@ export const commit = async (obj, opts) => {
32
111
  bodyContent = msg.body
33
112
  }
34
113
 
35
- // If inline-body-key is "data", put content in data field
36
- if (inlineBodyKey === "data") {
37
- body.data = bodyContent
114
+ // Put body content under the original field name (e.g., "data", "json")
115
+ if (inlineBodyKey) {
116
+ body[inlineBodyKey] = bodyContent
38
117
  } else {
39
118
  body.body = bodyContent
40
119
  }
41
120
  }
42
121
 
43
- // Remove inline-body-key from the final body as it's just metadata
44
- delete body["inline-body-key"]
122
+ // Include non-committed fields from the original object as JSON values.
123
+ // This covers: (1) body-key fields (arrays/objects encoded as multipart,
124
+ // which can't be included as raw multipart in JSON), and (2) any other
125
+ // fields excluded from signing. These fields are unsigned but present
126
+ // so HyperBEAM can parse them directly as JSON types.
127
+ // Use case-insensitive matching: signing normalizes keys to lowercase,
128
+ // but the original object may use mixed case (e.g., "To" vs "to").
129
+ const committedSetLower = new Set(components.map(v => (v === "@path" ? "path" : v).toLowerCase()))
130
+ // Also track lowercase keys already in body to prevent duplicates
131
+ const bodyKeysLower = new Set(Object.keys(body).map(k => k.toLowerCase()))
132
+ for (const [key, value] of Object.entries(obj)) {
133
+ // Skip HTTP method (not a data field)
134
+ if (key === "method") continue
135
+ // Skip if already in committed set (was signed)
136
+ if (committedSetLower.has(key.toLowerCase())) continue
137
+ // Skip if already in body (duplicate prevention)
138
+ if (bodyKeysLower.has(key.toLowerCase())) continue
139
+ if (value === undefined) continue
140
+ body[key] = value
141
+ bodyKeysLower.add(key.toLowerCase())
142
+ }
143
+
144
+ // Include ao-types so HyperBEAM knows how to convert string values to proper types.
145
+ // First check if it was set by the signer, otherwise compute it from the original object
146
+ let aoTypes = msg.headers["ao-types"]
147
+ if (!aoTypes) {
148
+ // Compute ao-types from the original typed values in obj
149
+ aoTypes = buildAoTypes(obj)
150
+ }
151
+ if (aoTypes) {
152
+ body["ao-types"] = aoTypes
153
+ }
45
154
 
46
- const hmacId = hmacid(msg.headers)
47
155
  const rsaId = rsaid(msg.headers)
48
156
  const pub = extractPubKey(msg.headers)
49
- const committer = toAddr(pub.toString("base64"))
50
- const meta = { alg: "rsa-pss-sha512", "commitment-device": "httpsig@1.0" }
51
- const meta2 = { alg: "hmac-sha256", "commitment-device": "httpsig@1.0" }
157
+ const pubKeyBase64 = pub.toString("base64")
158
+ const committer = toAddr(pubKeyBase64)
159
+
160
+ // Extract keyid from signature-input to ensure it matches what was signed
161
+ // Format: sig-xxx=("field1"...);alg="...";keyid="..."
162
+ // The keyid may already have a scheme prefix (e.g., "publickey:base64data")
163
+ const extractKeyidFromSigInput = (sigInput) => {
164
+ if (!sigInput) return null
165
+ const match = sigInput.match(/keyid="([^"]+)"/)
166
+ if (!match) return null
167
+ // Return as-is - the keyid already includes the prefix from signing
168
+ return match[1]
169
+ }
170
+ const keyid = extractKeyidFromSigInput(msg.headers["signature-input"]) || `publickey:${pubKeyBase64}`
171
+
172
+ // Build the list of committed fields.
173
+ // Always transform HTTPSig transport keys to AO-Core keys:
174
+ // - Replace content-digest with the body field name (HyperBEAM re-derives content-digest)
175
+ // - For native body keys ("body", "data"): also remove ao-body-key (HyperBEAM re-derives it)
176
+ // - For custom body keys (e.g., "json"): keep ao-body-key (HyperBEAM needs it to find the field)
177
+ let committedFields = components.map(v => v === "@path" ? "path" : v)
178
+ if (committedFields.includes("content-digest") && (inlineBodyKey || msg.body)) {
179
+ const bodyFieldName = inlineBodyKey || "body"
180
+ committedFields = committedFields.filter(k => k !== "content-digest")
181
+ if (!committedFields.includes(bodyFieldName)) {
182
+ committedFields.push(bodyFieldName)
183
+ }
184
+ }
185
+ if (isNativeBodyKey) {
186
+ // For native body keys, ao-body-key is a transport artifact - remove it
187
+ committedFields = committedFields.filter(k => k !== "ao-body-key")
188
+ }
189
+
190
+ // Extract just the base64 signature data from the header format "sig-xxx=:base64data:"
191
+ // HyperBEAM expects raw base64 without colons (uses b64fast:encode/decode)
192
+ const extractSignature = (sigHeader) => {
193
+ if (!sigHeader) return sigHeader
194
+ // Match the base64 data between colons after the label
195
+ const match = sigHeader.match(/=:([^:]+):/)
196
+ return match ? match[1] : sigHeader
197
+ }
198
+ const rawSignature = extractSignature(msg.headers.signature)
199
+ const meta = {
200
+ type: "rsa-pss-sha512",
201
+ alg: "rsa-pss-sha512",
202
+ "commitment-device": "httpsig@1.0",
203
+ keyid: keyid,
204
+ committed: committedFields
205
+ }
52
206
  const sigs = {
53
- signature: msg.headers.signature,
207
+ signature: rawSignature,
54
208
  "signature-input": msg.headers["signature-input"],
55
209
  }
210
+ // Only include the RSA commitment - HMAC commitments are created by HyperBEAM
211
+ // when needed and require the server's HMAC key which we don't have
56
212
  const committed = {
57
213
  commitments: {
58
214
  [rsaId]: { ...meta, committer, ...sigs },
59
- [hmacId]: { ...meta2, ...sigs },
60
215
  },
61
216
  ...body,
62
217
  }