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.
- package/cjs/bin_to_str.js +44 -0
- package/cjs/collect-body-keys.js +470 -0
- package/cjs/encode-array-item.js +110 -0
- package/cjs/encode-utils.js +236 -0
- package/cjs/encode.js +1318 -0
- package/cjs/erl_json.js +317 -0
- package/cjs/erl_str.js +1037 -0
- package/cjs/flat.js +222 -0
- package/cjs/http-message-signatures/httpbis.js +489 -0
- package/cjs/http-message-signatures/index.js +25 -0
- package/cjs/http-message-signatures/structured-header.js +129 -0
- package/cjs/httpsig.js +716 -0
- package/cjs/httpsig2.js +1160 -0
- package/cjs/id.js +470 -0
- package/cjs/index.js +63 -0
- package/cjs/send.js +194 -0
- package/cjs/signer-utils.js +617 -0
- package/cjs/signer.js +606 -0
- package/cjs/structured.js +296 -0
- package/cjs/test.js +27 -0
- package/cjs/utils.js +42 -0
- package/esm/bin_to_str.js +46 -0
- package/esm/collect-body-keys.js +436 -0
- package/esm/encode-array-item.js +112 -0
- package/esm/encode-utils.js +185 -0
- package/esm/encode.js +1219 -0
- package/esm/erl_json.js +289 -0
- package/esm/erl_str.js +1139 -0
- package/esm/flat.js +196 -0
- package/esm/http-message-signatures/httpbis.js +438 -0
- package/esm/http-message-signatures/index.js +4 -0
- package/esm/http-message-signatures/structured-header.js +105 -0
- package/esm/httpsig.js +658 -0
- package/esm/httpsig2.js +1097 -0
- package/esm/id.js +459 -0
- package/esm/index.js +4 -0
- package/esm/package.json +3 -0
- package/esm/send.js +124 -0
- package/esm/signer-utils.js +494 -0
- package/esm/signer.js +452 -0
- package/esm/structured.js +269 -0
- package/esm/test.js +6 -0
- package/esm/utils.js +28 -0
- package/package.json +28 -0
package/esm/signer.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { httpsig_from, httpsig_to } from "./httpsig.js"
|
|
2
|
+
import { structured_from, structured_to } from "./structured.js"
|
|
3
|
+
import { erl_json_from, erl_json_to, normalize } from "./erl_json.js"
|
|
4
|
+
import { enc } from "./encode.js"
|
|
5
|
+
import { isBytes } from "./encode-utils.js"
|
|
6
|
+
import { createSigner } from "@permaweb/aoconnect"
|
|
7
|
+
import { toHttpSigner } from "./send.js"
|
|
8
|
+
|
|
9
|
+
// Export verify from signer-utils.js for compatibility
|
|
10
|
+
export { verify } from "./signer-utils.js"
|
|
11
|
+
|
|
12
|
+
// Helper to check if an array contains binary data
|
|
13
|
+
const arrayHasBinaryData = arr => {
|
|
14
|
+
if (!Array.isArray(arr)) return false
|
|
15
|
+
|
|
16
|
+
return arr.some(item => {
|
|
17
|
+
if (isBytes(item)) return true
|
|
18
|
+
if (Array.isArray(item)) return arrayHasBinaryData(item)
|
|
19
|
+
return false
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper to check if a string contains non-printable characters
|
|
24
|
+
const hasNonPrintableChars = str => {
|
|
25
|
+
if (typeof str !== "string") return false
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < str.length; i++) {
|
|
28
|
+
const code = str.charCodeAt(i)
|
|
29
|
+
// Allow only printable ASCII (32-126)
|
|
30
|
+
// Note: tabs (9), newlines (10), and carriage returns (13) are not allowed in HTTP headers
|
|
31
|
+
if (code < 32 || code > 126) {
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isValid = encoded => {
|
|
39
|
+
if (!encoded || typeof encoded !== "object") return false
|
|
40
|
+
|
|
41
|
+
// Check if all header values are valid for HTTP headers
|
|
42
|
+
for (const [key, value] of Object.entries(encoded)) {
|
|
43
|
+
if (key === "body") {
|
|
44
|
+
// Body can be string, Buffer, or undefined
|
|
45
|
+
if (
|
|
46
|
+
value !== undefined &&
|
|
47
|
+
typeof value !== "string" &&
|
|
48
|
+
!Buffer.isBuffer(value)
|
|
49
|
+
) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
// All other fields (headers) must be strings or numbers
|
|
54
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
55
|
+
// Check for Buffer in headers - this will fail HTTP signing
|
|
56
|
+
if (Buffer.isBuffer(value) || isBytes(value)) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if string contains non-printable characters
|
|
63
|
+
if (typeof value === "string" && hasNonPrintableChars(value)) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if object contains any binary data or arrays with binary data
|
|
73
|
+
const hasBinaryData = obj => {
|
|
74
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
75
|
+
if (key === "path") continue
|
|
76
|
+
|
|
77
|
+
if (isBytes(value)) {
|
|
78
|
+
return true
|
|
79
|
+
} else if (Array.isArray(value) && arrayHasBinaryData(value)) {
|
|
80
|
+
return true
|
|
81
|
+
} else if (
|
|
82
|
+
typeof value === "object" &&
|
|
83
|
+
value !== null &&
|
|
84
|
+
!Array.isArray(value)
|
|
85
|
+
) {
|
|
86
|
+
// Check nested objects
|
|
87
|
+
for (const [k, v] of Object.entries(value)) {
|
|
88
|
+
if (isBytes(v)) {
|
|
89
|
+
return true
|
|
90
|
+
} else if (Array.isArray(v) && arrayHasBinaryData(v)) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Helper to check if value is a simple array that should use structured fields
|
|
100
|
+
const isSimpleArray = value => {
|
|
101
|
+
if (!Array.isArray(value)) return false
|
|
102
|
+
|
|
103
|
+
return value.every(item => {
|
|
104
|
+
// Simple types that can be in structured field lists
|
|
105
|
+
if (typeof item === "string") return true
|
|
106
|
+
if (typeof item === "number") return true
|
|
107
|
+
if (typeof item === "boolean") return true
|
|
108
|
+
if (isBytes(item)) return true
|
|
109
|
+
|
|
110
|
+
// Complex types cannot be in structured field lists
|
|
111
|
+
if (item && typeof item === "object") return false
|
|
112
|
+
|
|
113
|
+
return true
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Helper to encode array as structured field list
|
|
118
|
+
const encodeAsStructuredFieldList = arr => {
|
|
119
|
+
return arr
|
|
120
|
+
.map(item => {
|
|
121
|
+
if (typeof item === "string") {
|
|
122
|
+
// String values are quoted
|
|
123
|
+
return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
124
|
+
} else if (typeof item === "number") {
|
|
125
|
+
// Numbers are bare
|
|
126
|
+
return String(item)
|
|
127
|
+
} else if (typeof item === "boolean") {
|
|
128
|
+
// Booleans use ?0 or ?1
|
|
129
|
+
return item ? "?1" : "?0"
|
|
130
|
+
} else if (isBytes(item)) {
|
|
131
|
+
// Binary data as byte sequences
|
|
132
|
+
const buffer = Buffer.isBuffer(item) ? item : Buffer.from(item)
|
|
133
|
+
return `:${buffer.toString("base64")}:`
|
|
134
|
+
} else {
|
|
135
|
+
// Fallback
|
|
136
|
+
return `"${String(item)}"`
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.join(", ")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const smartSign = async (obj, path) => {
|
|
143
|
+
try {
|
|
144
|
+
// Filter out undefined values
|
|
145
|
+
const filtered = filterUndefined(obj)
|
|
146
|
+
|
|
147
|
+
// Check if we can encode everything as headers (no multipart needed)
|
|
148
|
+
let canUseSimpleEncoding = true
|
|
149
|
+
let hasBodyField = false
|
|
150
|
+
|
|
151
|
+
for (const [key, value] of Object.entries(filtered)) {
|
|
152
|
+
if (key === "path") continue
|
|
153
|
+
|
|
154
|
+
// Check if this is the "body" field
|
|
155
|
+
if (key === "body" || key === "data") {
|
|
156
|
+
hasBodyField = true
|
|
157
|
+
// Only use multipart if body/data is actually binary
|
|
158
|
+
if (isBytes(value)) {
|
|
159
|
+
canUseSimpleEncoding = false
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Complex nested objects need multipart
|
|
165
|
+
if (
|
|
166
|
+
value &&
|
|
167
|
+
typeof value === "object" &&
|
|
168
|
+
!Array.isArray(value) &&
|
|
169
|
+
!isBytes(value)
|
|
170
|
+
) {
|
|
171
|
+
if (Object.keys(value).length > 0) {
|
|
172
|
+
canUseSimpleEncoding = false
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Arrays with complex items need multipart
|
|
178
|
+
if (Array.isArray(value) && !isSimpleArray(value)) {
|
|
179
|
+
canUseSimpleEncoding = false
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (canUseSimpleEncoding) {
|
|
185
|
+
// Build a simple message that won't trigger multipart
|
|
186
|
+
const message = { path: path || filtered.path || "/~wao@1.0/httpsig" }
|
|
187
|
+
const types = []
|
|
188
|
+
|
|
189
|
+
for (const [key, value] of Object.entries(filtered)) {
|
|
190
|
+
if (key === "path") continue
|
|
191
|
+
|
|
192
|
+
if (value === "") {
|
|
193
|
+
types.push(`${key}="empty-binary"`)
|
|
194
|
+
} else if (Array.isArray(value) && value.length === 0) {
|
|
195
|
+
types.push(`${key}="empty-list"`)
|
|
196
|
+
} else if (
|
|
197
|
+
value &&
|
|
198
|
+
typeof value === "object" &&
|
|
199
|
+
Object.keys(value).length === 0
|
|
200
|
+
) {
|
|
201
|
+
types.push(`${key}="empty-message"`)
|
|
202
|
+
} else if (isSimpleArray(value)) {
|
|
203
|
+
types.push(`${key}="list"`)
|
|
204
|
+
message[key] = encodeAsStructuredFieldList(value)
|
|
205
|
+
} else if (typeof value === "number") {
|
|
206
|
+
types.push(
|
|
207
|
+
`${key}="${Number.isInteger(value) ? "integer" : "float"}"`
|
|
208
|
+
)
|
|
209
|
+
message[key] = String(value)
|
|
210
|
+
} else if (typeof value === "boolean") {
|
|
211
|
+
types.push(`${key}="atom"`)
|
|
212
|
+
message[key] = String(value)
|
|
213
|
+
} else if (value === null || value === undefined) {
|
|
214
|
+
types.push(`${key}="atom"`)
|
|
215
|
+
message[key] = String(value)
|
|
216
|
+
} else if (typeof value === "string") {
|
|
217
|
+
message[key] = value
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (types.length > 0) {
|
|
222
|
+
message["ao-types"] = types.join(", ")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return httpsig_to(message)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// For complex structures that need multipart, use enc()
|
|
229
|
+
const normalized = normalize({
|
|
230
|
+
...filtered,
|
|
231
|
+
path: path || "/~wao@1.0/httpsig",
|
|
232
|
+
})
|
|
233
|
+
const result = await enc(normalized)
|
|
234
|
+
|
|
235
|
+
// enc() returns { headers: {...}, body: ... }
|
|
236
|
+
// We need to flatten this for httpsig_to
|
|
237
|
+
const flattened = {
|
|
238
|
+
...result.headers,
|
|
239
|
+
body: result.body,
|
|
240
|
+
path: normalized.path || path || "/~wao@1.0/httpsig",
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// httpsig_to expects the structured format
|
|
244
|
+
const encoded = httpsig_to(flattened)
|
|
245
|
+
|
|
246
|
+
return encoded
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.error("Encoding failed:", error)
|
|
249
|
+
|
|
250
|
+
// Fallback: create a simple structure
|
|
251
|
+
const result = { path: path || "/~wao@1.0/httpsig" }
|
|
252
|
+
|
|
253
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
254
|
+
if (key === "path") continue
|
|
255
|
+
|
|
256
|
+
if (!isBytes(value) && value !== undefined) {
|
|
257
|
+
result[key] = value
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Internal sign function that matches the original signature
|
|
266
|
+
const _sign = async (obj, path) => {
|
|
267
|
+
// Filter out undefined values before processing
|
|
268
|
+
const filtered = filterUndefined(obj)
|
|
269
|
+
|
|
270
|
+
// If object contains binary data, use enc() directly
|
|
271
|
+
if (hasBinaryData(filtered)) {
|
|
272
|
+
return await smartSign(filtered, path)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Otherwise use the standard pipeline
|
|
276
|
+
const encoded = httpsig_to(
|
|
277
|
+
normalize(
|
|
278
|
+
structured_from(
|
|
279
|
+
normalize({ ...filtered, path: path || "/~wao@1.0/httpsig" })
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
// Check if the encoded result is valid for HTTP headers
|
|
285
|
+
if (!isValid(encoded)) {
|
|
286
|
+
return await smartSign(filtered, path)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return encoded
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Helper to join URL and path
|
|
293
|
+
const joinUrl = ({ url, path }) => {
|
|
294
|
+
if (path.startsWith("http://") || path.startsWith("https://")) return path
|
|
295
|
+
const normalizedPath = path.startsWith("/") ? path : "/" + path
|
|
296
|
+
return url.endsWith("/")
|
|
297
|
+
? url.slice(0, -1) + normalizedPath
|
|
298
|
+
: url + normalizedPath
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Main sign function that matches signer.js API
|
|
302
|
+
export async function sign({ url, path, msg: encoded, jwk, signPath = true }) {
|
|
303
|
+
const signer = createSigner(jwk, url)
|
|
304
|
+
const { body = null, ...headers } = encoded
|
|
305
|
+
let _enc = { headers }
|
|
306
|
+
if (body) _enc.body = new Blob([body])
|
|
307
|
+
|
|
308
|
+
const headersObj = _enc.headers || {}
|
|
309
|
+
const bodyData = _enc.body || undefined
|
|
310
|
+
let url_path = typeof signPath === "string" ? signPath : path
|
|
311
|
+
const _url = joinUrl({ url, path: url_path })
|
|
312
|
+
|
|
313
|
+
headersObj["path"] = path
|
|
314
|
+
if (bodyData && !headersObj["content-length"]) {
|
|
315
|
+
const bodySize = bodyData.size || bodyData.byteLength || 0
|
|
316
|
+
if (bodySize > 0) headersObj["content-length"] = String(bodySize)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const lowercaseHeaders = {}
|
|
320
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
321
|
+
lowercaseHeaders[key.toLowerCase()] = value
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const bodyKeys = headersObj["body-keys"]
|
|
325
|
+
? headersObj["body-keys"]
|
|
326
|
+
.replace(/"/g, "")
|
|
327
|
+
.split(",")
|
|
328
|
+
.map(k => k.trim())
|
|
329
|
+
: []
|
|
330
|
+
|
|
331
|
+
let isPath = false
|
|
332
|
+
const signingFields = Object.keys(lowercaseHeaders).filter(key => {
|
|
333
|
+
if (key === "path") isPath = true
|
|
334
|
+
return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
if (signPath !== false && isPath) signingFields.push("@path")
|
|
338
|
+
|
|
339
|
+
const signedRequest = await toHttpSigner(signer)({
|
|
340
|
+
request: { url: _url, method: "POST", headers: lowercaseHeaders },
|
|
341
|
+
fields: signingFields,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const finalHeaders = {}
|
|
345
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
346
|
+
finalHeaders[key] = value
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
finalHeaders["signature"] = signedRequest.headers["signature"]
|
|
350
|
+
finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
|
|
351
|
+
|
|
352
|
+
if (headersObj["body-keys"]) {
|
|
353
|
+
finalHeaders["body-keys"] = headersObj["body-keys"]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = { url: _url, method: "POST", headers: finalHeaders }
|
|
357
|
+
if (bodyData) result.body = bodyData
|
|
358
|
+
|
|
359
|
+
return result
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Helper function to recursively filter out undefined values
|
|
363
|
+
const filterUndefined = obj => {
|
|
364
|
+
if (obj === null || obj === undefined) return obj
|
|
365
|
+
if (Array.isArray(obj)) {
|
|
366
|
+
return obj.map(filterUndefined).filter(item => item !== undefined)
|
|
367
|
+
}
|
|
368
|
+
if (typeof obj === "object" && obj.constructor === Object) {
|
|
369
|
+
const filtered = {}
|
|
370
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
371
|
+
const filteredValue = filterUndefined(value)
|
|
372
|
+
if (filteredValue !== undefined) {
|
|
373
|
+
filtered[key] = filteredValue
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return filtered
|
|
377
|
+
}
|
|
378
|
+
return obj
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Signer factory function that matches signer.js API
|
|
382
|
+
export function signer(config) {
|
|
383
|
+
const { signer, url = "http://localhost:10001" } = config
|
|
384
|
+
if (!signer) throw new Error("Signer is required for mainnet mode")
|
|
385
|
+
|
|
386
|
+
return async (
|
|
387
|
+
fields,
|
|
388
|
+
{ encoded: _encoded = false, path: _path = true } = {}
|
|
389
|
+
) => {
|
|
390
|
+
const { path = "/relay/process", method = "POST", ...aoFields } = fields
|
|
391
|
+
|
|
392
|
+
// Filter out undefined values before encoding
|
|
393
|
+
const filteredFields = filterUndefined(aoFields)
|
|
394
|
+
const encoded = _encoded ? filteredFields : await enc(filteredFields)
|
|
395
|
+
|
|
396
|
+
const headersObj = encoded ? encoded.headers : {}
|
|
397
|
+
const body = encoded ? encoded.body : undefined
|
|
398
|
+
let url_path = typeof _path === "string" ? _path : path
|
|
399
|
+
const _url = joinUrl({ url, path: url_path })
|
|
400
|
+
|
|
401
|
+
headersObj["path"] = path
|
|
402
|
+
if (body && !headersObj["content-length"]) {
|
|
403
|
+
const bodySize = body.size || body.byteLength || 0
|
|
404
|
+
if (bodySize > 0) headersObj["content-length"] = String(bodySize)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const lowercaseHeaders = {}
|
|
408
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
409
|
+
lowercaseHeaders[key.toLowerCase()] = value
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const bodyKeys = headersObj["body-keys"]
|
|
413
|
+
? headersObj["body-keys"]
|
|
414
|
+
.replace(/"/g, "")
|
|
415
|
+
.split(",")
|
|
416
|
+
.map(k => k.trim())
|
|
417
|
+
: []
|
|
418
|
+
|
|
419
|
+
let isPath = false
|
|
420
|
+
const signingFields = Object.keys(lowercaseHeaders).filter(key => {
|
|
421
|
+
if (key === "path") isPath = true
|
|
422
|
+
return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
if (_path !== false && isPath) signingFields.push("@path")
|
|
426
|
+
|
|
427
|
+
const signedRequest = await toHttpSigner(signer)({
|
|
428
|
+
request: { url: _url, method, headers: lowercaseHeaders },
|
|
429
|
+
fields: signingFields,
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const finalHeaders = {}
|
|
433
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
434
|
+
finalHeaders[key] = value
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
finalHeaders["signature"] = signedRequest.headers["signature"]
|
|
438
|
+
finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
|
|
439
|
+
|
|
440
|
+
if (headersObj["body-keys"]) {
|
|
441
|
+
finalHeaders["body-keys"] = headersObj["body-keys"]
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const result = { url: _url, method, headers: finalHeaders }
|
|
445
|
+
if (body) result.body = body
|
|
446
|
+
|
|
447
|
+
return result
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Export the internal sign function for backward compatibility
|
|
452
|
+
export { _sign as signInternal }
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured field codec for JavaScript-Erlang interoperability
|
|
3
|
+
* Implements the same behavior as dev_codec_structured.erl
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { erl_str_from } from "./erl_str.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convert from structured format
|
|
10
|
+
* @param {string|object} input - Erlang term string or JavaScript object
|
|
11
|
+
* @returns {object} - Processed object
|
|
12
|
+
*/
|
|
13
|
+
export function structured_from(input) {
|
|
14
|
+
// If input is a string (Erlang response), parse it
|
|
15
|
+
if (typeof input === "string") {
|
|
16
|
+
return erl_str_from(input, false)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Otherwise process the object like Erlang's from/1
|
|
20
|
+
return from(input)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert to structured format
|
|
25
|
+
* @param {*} obj - JavaScript object
|
|
26
|
+
* @returns {*} - Structured format object
|
|
27
|
+
*/
|
|
28
|
+
export function structured_to(obj) {
|
|
29
|
+
// TODO: Implement the inverse of structured_from
|
|
30
|
+
return obj
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if an array should be converted to numbered map
|
|
35
|
+
* Rules based on Erlang behavior:
|
|
36
|
+
* 1. Contains any objects/maps → convert
|
|
37
|
+
* 2. Contains empty arrays (but NOT empty buffers) → convert
|
|
38
|
+
* 3. All items are arrays (array of arrays) → convert
|
|
39
|
+
* 4. Otherwise → encode as string
|
|
40
|
+
*/
|
|
41
|
+
function shouldConvertToNumberedMap(arr) {
|
|
42
|
+
let allArrays = true
|
|
43
|
+
let hasObjects = false
|
|
44
|
+
let hasEmptyArrays = false
|
|
45
|
+
|
|
46
|
+
for (const item of arr) {
|
|
47
|
+
// Check for objects (not arrays or buffers)
|
|
48
|
+
if (
|
|
49
|
+
typeof item === "object" &&
|
|
50
|
+
item !== null &&
|
|
51
|
+
!Array.isArray(item) &&
|
|
52
|
+
!Buffer.isBuffer(item)
|
|
53
|
+
) {
|
|
54
|
+
hasObjects = true
|
|
55
|
+
}
|
|
56
|
+
// Check for empty arrays only (NOT empty buffers)
|
|
57
|
+
else if (Array.isArray(item) && item.length === 0) {
|
|
58
|
+
hasEmptyArrays = true
|
|
59
|
+
}
|
|
60
|
+
// Track if all items are arrays
|
|
61
|
+
else if (!Array.isArray(item)) {
|
|
62
|
+
allArrays = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Convert if: has objects, has empty arrays, or all items are non-empty arrays
|
|
67
|
+
return (
|
|
68
|
+
hasObjects ||
|
|
69
|
+
hasEmptyArrays ||
|
|
70
|
+
(allArrays && arr.length > 0 && arr.every(item => Array.isArray(item)))
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Implementation of Erlang's dev_codec_structured:from/1
|
|
76
|
+
*/
|
|
77
|
+
function from(msg) {
|
|
78
|
+
// Handle non-map values
|
|
79
|
+
if (
|
|
80
|
+
msg instanceof Buffer ||
|
|
81
|
+
typeof msg !== "object" ||
|
|
82
|
+
msg === null ||
|
|
83
|
+
Array.isArray(msg)
|
|
84
|
+
) {
|
|
85
|
+
return msg
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Normalize keys first
|
|
89
|
+
const normalizedMap = {}
|
|
90
|
+
for (const [key, value] of Object.entries(msg)) {
|
|
91
|
+
const normKey = key.toLowerCase()
|
|
92
|
+
normalizedMap[normKey] = value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get sorted keys (normalized)
|
|
96
|
+
const sortedKeys = Object.keys(normalizedMap).sort()
|
|
97
|
+
|
|
98
|
+
const types = []
|
|
99
|
+
const values = []
|
|
100
|
+
|
|
101
|
+
// Process each key in sorted order
|
|
102
|
+
for (const normKey of sortedKeys) {
|
|
103
|
+
const value = normalizedMap[normKey]
|
|
104
|
+
|
|
105
|
+
// Handle empty values
|
|
106
|
+
if (value === "" || (value instanceof Buffer && value.length === 0)) {
|
|
107
|
+
types.push([normKey, "empty-binary"])
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
112
|
+
types.push([normKey, "empty-list"])
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
typeof value === "object" &&
|
|
118
|
+
value !== null &&
|
|
119
|
+
!Array.isArray(value) &&
|
|
120
|
+
!(value instanceof Buffer) &&
|
|
121
|
+
Object.keys(value).length === 0
|
|
122
|
+
) {
|
|
123
|
+
types.push([normKey, "empty-message"])
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Handle binary/string values
|
|
128
|
+
if (value instanceof Buffer || value instanceof Uint8Array) {
|
|
129
|
+
// Keep buffers as buffers
|
|
130
|
+
values.push([normKey, value])
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof value === "string") {
|
|
135
|
+
// Keep strings as strings
|
|
136
|
+
values.push([normKey, value])
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle nested maps
|
|
141
|
+
if (typeof value === "object" && !Array.isArray(value) && value !== null) {
|
|
142
|
+
values.push([normKey, from(value)])
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle arrays
|
|
147
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
148
|
+
if (shouldConvertToNumberedMap(value)) {
|
|
149
|
+
// Convert to numbered map (1-based indexing)
|
|
150
|
+
const numberedMap = {}
|
|
151
|
+
value.forEach((item, idx) => {
|
|
152
|
+
numberedMap[(idx + 1).toString()] = item
|
|
153
|
+
})
|
|
154
|
+
types.push([normKey, "list"])
|
|
155
|
+
values.push([normKey, from(numberedMap)])
|
|
156
|
+
} else {
|
|
157
|
+
// Encode as list string
|
|
158
|
+
const [type, encoded] = encodeValue(value)
|
|
159
|
+
types.push([normKey, type])
|
|
160
|
+
values.push([normKey, encoded])
|
|
161
|
+
}
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle typed values (need encoding)
|
|
166
|
+
if (
|
|
167
|
+
typeof value === "symbol" ||
|
|
168
|
+
typeof value === "number" ||
|
|
169
|
+
Array.isArray(value) ||
|
|
170
|
+
typeof value === "boolean" ||
|
|
171
|
+
value === null
|
|
172
|
+
) {
|
|
173
|
+
const [type, encoded] = encodeValue(value)
|
|
174
|
+
types.push([normKey, type])
|
|
175
|
+
values.push([normKey, encoded])
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Build result
|
|
181
|
+
const result = {}
|
|
182
|
+
|
|
183
|
+
// Add ao-types if present
|
|
184
|
+
if (types.length > 0) {
|
|
185
|
+
result["ao-types"] = types.map(([k, t]) => `${k}="${t}"`).join(", ")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Add values (but NOT empty values)
|
|
189
|
+
for (const [k, v] of values) {
|
|
190
|
+
result[k] = v
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Encode a value with its type
|
|
198
|
+
*/
|
|
199
|
+
function encodeValue(value) {
|
|
200
|
+
// Null (as atom)
|
|
201
|
+
if (value === null) {
|
|
202
|
+
return ["atom", '"null"']
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Integer
|
|
206
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
207
|
+
return ["integer", value.toString()]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Float
|
|
211
|
+
if (typeof value === "number") {
|
|
212
|
+
// Format like Erlang with scientific notation
|
|
213
|
+
let str = value.toExponential(20)
|
|
214
|
+
// Remove trailing zeros but keep at least one
|
|
215
|
+
str = str.replace(/(\.\d*?)0+e/, "$1e").replace(/\.e/, ".0e")
|
|
216
|
+
// Ensure 2-digit exponent
|
|
217
|
+
str = str.replace(/e([+-])(\d)$/, "e$10$2")
|
|
218
|
+
return ["float", str]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Boolean (as atom)
|
|
222
|
+
if (typeof value === "boolean") {
|
|
223
|
+
return ["atom", `"${value}"`]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Symbol (as atom)
|
|
227
|
+
if (typeof value === "symbol") {
|
|
228
|
+
const name = Symbol.keyFor(value) || value.description || ""
|
|
229
|
+
return ["atom", `"${name}"`]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// List
|
|
233
|
+
if (Array.isArray(value)) {
|
|
234
|
+
const parts = []
|
|
235
|
+
|
|
236
|
+
for (const item of value) {
|
|
237
|
+
if (item instanceof Buffer) {
|
|
238
|
+
// Empty buffer => empty string
|
|
239
|
+
parts.push(item.length === 0 ? '""' : `"${item.toString()}"`)
|
|
240
|
+
} else if (typeof item === "string") {
|
|
241
|
+
parts.push(`"${item}"`)
|
|
242
|
+
} else {
|
|
243
|
+
const [itemType, itemEncoded] = encodeValue(item)
|
|
244
|
+
|
|
245
|
+
if (itemType === "list") {
|
|
246
|
+
// Escape nested list quotes
|
|
247
|
+
const escaped = itemEncoded
|
|
248
|
+
.replace(/\\/g, "\\\\")
|
|
249
|
+
.replace(/"/g, '\\"')
|
|
250
|
+
parts.push(`"(ao-type-list) ${escaped}"`)
|
|
251
|
+
} else if (itemType === "atom") {
|
|
252
|
+
// Escape atom quotes
|
|
253
|
+
const escaped = itemEncoded.replace(/"/g, '\\"')
|
|
254
|
+
parts.push(`"(ao-type-atom) ${escaped}"`)
|
|
255
|
+
} else {
|
|
256
|
+
parts.push(`"(ao-type-${itemType}) ${itemEncoded}"`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return ["list", parts.join(", ")]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (value instanceof Buffer) {
|
|
265
|
+
return ["binary", value]
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return ["unknown", String(value)]
|
|
269
|
+
}
|