wao 0.32.0 → 0.32.2

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.
@@ -0,0 +1,438 @@
1
+ import {
2
+ parseDictionary,
3
+ parseItem,
4
+ serializeItem,
5
+ serializeList,
6
+ ByteSequence,
7
+ serializeDictionary,
8
+ parseList,
9
+ isInnerList,
10
+ isByteSequence,
11
+ Token,
12
+ } from "structured-headers"
13
+ import { Dictionary, parseHeader, quoteString } from "./structured-header.js"
14
+
15
+ // Default params if not provided
16
+ const defaultParams = ["created", "keyid", "alg", "expires"]
17
+
18
+ // Helper to check if message is a request
19
+ const isRequest = message => {
20
+ return message.method !== undefined
21
+ }
22
+
23
+ /**
24
+ * Components can be derived from requests or responses (which can also be bound to their request).
25
+ * The signature is essentially (component, params, signingSubject, supplementaryData)
26
+ *
27
+ * MODIFIED: This implementation matches Erlang behavior where @ components are treated as field lookups
28
+ */
29
+ export function deriveComponent(component, params, message, req) {
30
+ // MODIFIED: Match Erlang behavior - all @ components become field lookups
31
+ // The Erlang strips @ and calls extract_field for ALL derived components
32
+
33
+ // Remove @ prefix to match Erlang behavior
34
+ const fieldName = component.startsWith("@") ? component.slice(1) : component
35
+
36
+ // For all @ components, do a field lookup instead of deriving
37
+ // This matches the Erlang identifier_to_component behavior
38
+ if (component.startsWith("@")) {
39
+ try {
40
+ if (params.has("req") && req) {
41
+ return extractHeader(fieldName, params, req, undefined)
42
+ }
43
+ return extractHeader(fieldName, params, message, req)
44
+ } catch (e) {
45
+ // If field not found, return empty or throw based on component type
46
+ throw new Error(
47
+ `No field "${fieldName}" found for component "${component}"`
48
+ )
49
+ }
50
+ }
51
+
52
+ // This code path should not be reached for @ components anymore
53
+ // but keeping it for non-@ components
54
+ throw new Error(`Unsupported component "${component}"`)
55
+ }
56
+
57
+ export function extractHeader(header, params, messageOrHeaders, req) {
58
+ const headers = messageOrHeaders.headers || messageOrHeaders
59
+ const context = params.has("req") ? req?.headers : headers
60
+ if (!context) {
61
+ throw new Error("Missing request in request-response bound component")
62
+ }
63
+ const headerTuple = Object.entries(context).find(
64
+ ([name]) => name.toLowerCase() === header
65
+ )
66
+ if (!headerTuple) {
67
+ throw new Error(`No header "${header}" found in headers`)
68
+ }
69
+ const values = Array.isArray(headerTuple[1])
70
+ ? headerTuple[1]
71
+ : [headerTuple[1]]
72
+ if (params.has("bs") && (params.has("sf") || params.has("key"))) {
73
+ throw new Error("Cannot have both `bs` and (implicit) `sf` parameters")
74
+ }
75
+ if (params.has("sf") || params.has("key")) {
76
+ // strict encoding of field
77
+ const value = values.join(", ")
78
+ const parsed = parseHeader(value)
79
+ if (params.has("key") && !(parsed instanceof Dictionary)) {
80
+ throw new Error("Unable to parse header as dictionary")
81
+ }
82
+ if (params.has("key")) {
83
+ const key = params.get("key").toString()
84
+ if (!parsed.has(key)) {
85
+ throw new Error(`Unable to find key "${key}" in structured field`)
86
+ }
87
+ return [parsed.get(key)]
88
+ }
89
+ return [parsed.toString()]
90
+ }
91
+ if (params.has("bs")) {
92
+ return [
93
+ values
94
+ .map(val => {
95
+ const encoded = Buffer.from(val.trim().replace(/\n\s*/gm, " "))
96
+ return `:${encoded.toString("base64")}:`
97
+ })
98
+ .join(", "),
99
+ ]
100
+ }
101
+ // raw encoding
102
+ return [values.map(val => val.trim().replace(/\n\s*/gm, " ")).join(", ")]
103
+ }
104
+
105
+ function normaliseParams(params) {
106
+ const map = new Map()
107
+ params.forEach((value, key) => {
108
+ if (value instanceof ByteSequence) {
109
+ map.set(key, value.toBase64())
110
+ } else if (value instanceof Token) {
111
+ map.set(key, value.toString())
112
+ } else {
113
+ map.set(key, value)
114
+ }
115
+ })
116
+ return map
117
+ }
118
+
119
+ export function createSignatureBase(config, res, req) {
120
+ return config.fields.reduce((base, fieldName) => {
121
+ const [field, params] = parseItem(quoteString(fieldName))
122
+ const fieldParams = normaliseParams(params)
123
+ const lcFieldName = field.toLowerCase()
124
+ if (lcFieldName !== "@signature-params") {
125
+ let value = null
126
+ if (config.componentParser) {
127
+ value =
128
+ config.componentParser(lcFieldName, fieldParams, res, req) ?? null
129
+ }
130
+ if (value === null) {
131
+ value = field.startsWith("@")
132
+ ? deriveComponent(lcFieldName, fieldParams, res, req)
133
+ : extractHeader(lcFieldName, fieldParams, res, req)
134
+ }
135
+ base.push([serializeItem([field, params]), value])
136
+ }
137
+ return base
138
+ }, [])
139
+ }
140
+
141
+ export function formatSignatureBase(base) {
142
+ return base
143
+ .map(([key, value]) => {
144
+ const quotedKey = serializeItem(parseItem(quoteString(key)))
145
+
146
+ // MODIFIED: Special handling to match Erlang behavior
147
+ // If the key is "@path", format it as "path" (without @) in the signature base
148
+ let formattedKey = quotedKey
149
+ if (quotedKey === '"@path"') {
150
+ formattedKey = '"path"'
151
+ }
152
+
153
+ return value.map(val => `${formattedKey}: ${val}`).join("\n")
154
+ })
155
+ .join("\n")
156
+ }
157
+
158
+ export function createSigningParameters(config) {
159
+ const now = new Date()
160
+ return (config.params ?? defaultParams).reduce((params, paramName) => {
161
+ let value = ""
162
+ switch (paramName.toLowerCase()) {
163
+ case "created":
164
+ // created is optional but recommended. If created is supplied but is null, that's an explicit
165
+ // instruction to *not* include the created parameter
166
+ if (config.paramValues?.created !== null) {
167
+ const created = config.paramValues?.created ?? now
168
+ value = Math.floor(created.getTime() / 1000)
169
+ }
170
+ break
171
+ case "expires":
172
+ // attempt to obtain an explicit expires time, otherwise create one that is 300 seconds after
173
+ // creation. Don't add an expires time if there is no created time
174
+ if (
175
+ config.paramValues?.expires ||
176
+ config.paramValues?.created !== null
177
+ ) {
178
+ const expires =
179
+ config.paramValues?.expires ??
180
+ new Date((config.paramValues?.created ?? now).getTime() + 300000)
181
+ value = Math.floor(expires.getTime() / 1000)
182
+ }
183
+ break
184
+ case "keyid": {
185
+ // attempt to obtain the keyid omit if missing
186
+ const kid = config.paramValues?.keyid ?? config.key.id ?? null
187
+ if (kid) {
188
+ value = kid.toString()
189
+ }
190
+ break
191
+ }
192
+ case "alg": {
193
+ // if there is no alg, but it's listed as a required parameter, we should probably
194
+ // throw an error - the problem is that if it's in the default set of params, do we
195
+ // really want to throw if there's no keyid?
196
+ const alg = config.paramValues?.alg ?? config.key.alg ?? null
197
+ if (alg) {
198
+ value = alg.toString()
199
+ }
200
+ break
201
+ }
202
+ default:
203
+ if (config.paramValues?.[paramName] instanceof Date) {
204
+ value = Math.floor(config.paramValues[paramName].getTime() / 1000)
205
+ } else if (config.paramValues?.[paramName]) {
206
+ value = config.paramValues[paramName]
207
+ }
208
+ }
209
+ if (value) {
210
+ params.set(paramName, value)
211
+ }
212
+ return params
213
+ }, new Map())
214
+ }
215
+
216
+ export function augmentHeaders(headers, signature, signatureInput, name) {
217
+ let signatureHeaderName = "Signature"
218
+ let signatureInputHeaderName = "Signature-Input"
219
+ let signatureHeader = new Map()
220
+ let inputHeader = new Map()
221
+ // check to see if there are already signature/signature-input headers
222
+ // if there are we want to store the current (case-sensitive) name of the header
223
+ // and we want to parse out the current values so we can append our new signature
224
+ for (const header in headers) {
225
+ switch (header.toLowerCase()) {
226
+ case "signature": {
227
+ signatureHeaderName = header
228
+ signatureHeader = parseDictionary(
229
+ Array.isArray(headers[header])
230
+ ? headers[header].join(", ")
231
+ : headers[header]
232
+ )
233
+ break
234
+ }
235
+ case "signature-input":
236
+ signatureInputHeaderName = header
237
+ inputHeader = parseDictionary(
238
+ Array.isArray(headers[header])
239
+ ? headers[header].join(", ")
240
+ : headers[header]
241
+ )
242
+ break
243
+ }
244
+ }
245
+ // find a unique signature name for the header. Check if any existing headers already use
246
+ // the name we intend to use, if there are, add incrementing numbers to the signature name
247
+ // until we have a unique name to use
248
+ let signatureName = name ?? "sig"
249
+ if (signatureHeader.has(signatureName) || inputHeader.has(signatureName)) {
250
+ let count = 0
251
+ while (
252
+ signatureHeader.has(`${signatureName}${count}`) ||
253
+ inputHeader.has(`${signatureName}${count}`)
254
+ ) {
255
+ count++
256
+ }
257
+ signatureName += count.toString()
258
+ }
259
+ // append our signature and signature-inputs to the headers and return
260
+ signatureHeader.set(signatureName, [
261
+ new ByteSequence(signature.toString("base64")),
262
+ new Map(),
263
+ ])
264
+ inputHeader.set(signatureName, parseList(signatureInput)[0])
265
+ return {
266
+ ...headers,
267
+ [signatureHeaderName]: serializeDictionary(signatureHeader),
268
+ [signatureInputHeaderName]: serializeDictionary(inputHeader),
269
+ }
270
+ }
271
+
272
+ export async function signMessage(config, message, req) {
273
+ const signingParameters = createSigningParameters(config)
274
+ const signatureBase = createSignatureBase(
275
+ {
276
+ fields: config.fields ?? [],
277
+ componentParser: config.componentParser,
278
+ },
279
+ message,
280
+ req
281
+ )
282
+ const signatureInput = serializeList([
283
+ [signatureBase.map(([item]) => parseItem(item)), signingParameters],
284
+ ])
285
+ signatureBase.push(['"@signature-params"', [signatureInput]])
286
+ const base = formatSignatureBase(signatureBase)
287
+ // call sign
288
+ const signature = await config.key.sign(Buffer.from(base))
289
+ return {
290
+ ...message,
291
+ headers: augmentHeaders(
292
+ { ...message.headers },
293
+ signature,
294
+ signatureInput,
295
+ config.name
296
+ ),
297
+ }
298
+ }
299
+
300
+ export async function verifyMessage(config, message, req) {
301
+ const { signatures, signatureInputs } = Object.entries(
302
+ message.headers
303
+ ).reduce((accum, [name, value]) => {
304
+ switch (name.toLowerCase()) {
305
+ case "signature":
306
+ return Object.assign(accum, {
307
+ signatures: parseDictionary(
308
+ Array.isArray(value) ? value.join(", ") : value
309
+ ),
310
+ })
311
+ case "signature-input":
312
+ return Object.assign(accum, {
313
+ signatureInputs: parseDictionary(
314
+ Array.isArray(value) ? value.join(", ") : value
315
+ ),
316
+ })
317
+ default:
318
+ return accum
319
+ }
320
+ }, {})
321
+ // no signatures means an indeterminate result
322
+ if (!signatures?.size && !signatureInputs?.size) {
323
+ return null
324
+ }
325
+ // a missing header means we can't verify the signatures
326
+ if (!signatures?.size || !signatureInputs?.size) {
327
+ throw new Error("Incomplete signature headers")
328
+ }
329
+ const now = Math.floor(Date.now() / 1000)
330
+ const tolerance = config.tolerance ?? 0
331
+ const notAfter =
332
+ config.notAfter instanceof Date
333
+ ? Math.floor(config.notAfter.getTime() / 1000)
334
+ : (config.notAfter ?? now)
335
+ const maxAge = config.maxAge ?? null
336
+ const requiredParams = config.requiredParams ?? []
337
+ const requiredFields = config.requiredFields ?? []
338
+ return Array.from(signatureInputs.entries()).reduce(
339
+ async (prev, [name, input]) => {
340
+ const signatureParams = Array.from(input[1].entries()).reduce(
341
+ (params, [key, value]) => {
342
+ if (value instanceof ByteSequence) {
343
+ Object.assign(params, {
344
+ [key]: value.toBase64(),
345
+ })
346
+ } else if (value instanceof Token) {
347
+ Object.assign(params, {
348
+ [key]: value.toString(),
349
+ })
350
+ } else if (key === "created" || key === "expired") {
351
+ Object.assign(params, {
352
+ [key]: new Date(value * 1000),
353
+ })
354
+ } else {
355
+ Object.assign(params, {
356
+ [key]: value,
357
+ })
358
+ }
359
+ return params
360
+ },
361
+ {}
362
+ )
363
+ const [result, key] = await Promise.all([
364
+ prev.catch(e => e),
365
+ config.keyLookup(signatureParams),
366
+ ])
367
+ // @todo - confirm this is all working as expected
368
+ if (config.all && !key) {
369
+ throw new Error("Unknown key")
370
+ }
371
+ if (!key) {
372
+ if (result instanceof Error) {
373
+ throw result
374
+ }
375
+ return result
376
+ }
377
+ if (
378
+ input[1].has("alg") &&
379
+ key.algs?.includes(input[1].get("alg")) === false
380
+ ) {
381
+ throw new Error("Unsupported key algorithm")
382
+ }
383
+ if (!isInnerList(input)) {
384
+ throw new Error("Malformed signature input")
385
+ }
386
+ const hasRequiredParams = requiredParams.every(param =>
387
+ input[1].has(param)
388
+ )
389
+ if (!hasRequiredParams) {
390
+ throw new Error("Missing required signature parameters")
391
+ }
392
+ // this could be tricky, what if we say "@method" but there is "@method;req"
393
+ const hasRequiredFields = requiredFields.every(field =>
394
+ input[0].some(([fieldName]) => fieldName === field)
395
+ )
396
+ if (!hasRequiredFields) {
397
+ throw new Error("Missing required signed fields")
398
+ }
399
+ if (input[1].has("created")) {
400
+ const created = input[1].get("created") - tolerance
401
+ // maxAge overrides expires.
402
+ // signature is older than maxAge
403
+ if ((maxAge && now - created > maxAge) || created > notAfter) {
404
+ throw new Error("Signature is too old")
405
+ }
406
+ }
407
+ if (input[1].has("expires")) {
408
+ const expires = input[1].get("expires") + tolerance
409
+ // expired signature
410
+ if (now > expires) {
411
+ throw new Error("Signature has expired")
412
+ }
413
+ }
414
+ // now look to verify the signature! Build the expected "signing base" and verify it!
415
+ const fields = input[0].map(item => serializeItem(item))
416
+ const signingBase = createSignatureBase(
417
+ { fields, componentParser: config.componentParser },
418
+ message,
419
+ req
420
+ )
421
+ signingBase.push(['"@signature-params"', [serializeList([input])]])
422
+ const base = formatSignatureBase(signingBase)
423
+ const signature = signatures.get(name)
424
+ if (!signature) {
425
+ throw new Error("No corresponding signature for input")
426
+ }
427
+ if (!isByteSequence(signature[0])) {
428
+ throw new Error("Malformed signature")
429
+ }
430
+ return key.verify(
431
+ Buffer.from(base),
432
+ Buffer.from(signature[0].toBase64(), "base64"),
433
+ signatureParams
434
+ )
435
+ },
436
+ Promise.resolve(null)
437
+ )
438
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./httpbis.js"
2
+ import * as httpbis from "./httpbis.js"
3
+ export { httpbis }
4
+ export { httpbis as default }
@@ -0,0 +1,105 @@
1
+ import {
2
+ isInnerList,
3
+ parseDictionary,
4
+ parseItem,
5
+ parseList,
6
+ serializeDictionary,
7
+ serializeInnerList,
8
+ serializeItem,
9
+ serializeList,
10
+ } from "structured-headers"
11
+
12
+ export class Dictionary {
13
+ constructor(input) {
14
+ this.raw = input
15
+ this.parsed = parseDictionary(input)
16
+ }
17
+
18
+ toString() {
19
+ return this.serialize()
20
+ }
21
+
22
+ serialize() {
23
+ return serializeDictionary(this.parsed)
24
+ }
25
+
26
+ has(key) {
27
+ return this.parsed.has(key)
28
+ }
29
+
30
+ get(key) {
31
+ const value = this.parsed.get(key)
32
+ if (!value) {
33
+ return value
34
+ }
35
+ if (isInnerList(value)) {
36
+ return serializeInnerList(value)
37
+ }
38
+ return serializeItem(value)
39
+ }
40
+ }
41
+
42
+ export class List {
43
+ constructor(input) {
44
+ this.raw = input
45
+ this.parsed = parseList(input)
46
+ }
47
+
48
+ toString() {
49
+ return this.serialize()
50
+ }
51
+
52
+ serialize() {
53
+ return serializeList(this.parsed)
54
+ }
55
+ }
56
+
57
+ export class Item {
58
+ constructor(input) {
59
+ this.raw = input
60
+ this.parsed = parseItem(input)
61
+ }
62
+
63
+ toString() {
64
+ return this.serialize()
65
+ }
66
+
67
+ serialize() {
68
+ return serializeItem(this.parsed)
69
+ }
70
+ }
71
+
72
+ export function parseHeader(header) {
73
+ const classes = [List, Dictionary, Item]
74
+ for (let i = 0; i < classes.length; i++) {
75
+ try {
76
+ return new classes[i](header)
77
+ } catch (e) {
78
+ // noop
79
+ }
80
+ }
81
+ throw new Error("Unable to parse header as structured field")
82
+ }
83
+
84
+ /**
85
+ * This allows consumers of the library to supply field specifications that aren't
86
+ * strictly "structured fields". Really a string must start with a `"` but that won't
87
+ * tend to happen in our configs.
88
+ *
89
+ * @param {string} input
90
+ * @returns {string}
91
+ */
92
+ export function quoteString(input) {
93
+ // if it's not quoted, attempt to quote
94
+ if (!input.startsWith('"')) {
95
+ // try to split the structured field
96
+ const [name, ...rest] = input.split(";")
97
+ // no params, just quote the whole thing
98
+ if (!rest.length) {
99
+ return `"${name}"`
100
+ }
101
+ // quote the first part and put the rest back as it was
102
+ return `"${name}";${rest.join(";")}`
103
+ }
104
+ return input
105
+ }
package/esm/send.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import base64url from "base64url"
2
- import { httpbis } from "http-message-signatures"
2
+ import { httpbis } from "./http-message-signatures/index.js"
3
3
  import { parseItem, serializeList } from "structured-headers"
4
4
  const {
5
5
  augmentHeaders,
@@ -36,7 +36,7 @@ export async function send(signedMsg, fetchImpl = fetch) {
36
36
  return { ...from(http), ...http }
37
37
  }
38
38
 
39
- const httpSigName = address => {
39
+ export const httpSigName = address => {
40
40
  const decoded = base64url.toBuffer(address)
41
41
  const hexString = [...decoded.subarray(1, 9)]
42
42
  .map(byte => byte.toString(16).padStart(2, "0"))
@@ -76,7 +76,13 @@ export const toHttpSigner = signer => {
76
76
  },
77
77
  })
78
78
 
79
- const signatureBaseArray = createSignatureBase({ fields }, request)
79
+ // SORT THE FIELDS HERE to match Erlang's lists:sort(maps:keys(Enc))
80
+ const sortedFields = [...fields].sort()
81
+
82
+ const signatureBaseArray = createSignatureBase(
83
+ { fields: sortedFields },
84
+ request
85
+ )
80
86
  signatureInput = serializeList([
81
87
  [
82
88
  signatureBaseArray.map(([item]) => parseItem(item)),
@@ -89,7 +95,6 @@ export const toHttpSigner = signer => {
89
95
  return new TextEncoder().encode(signatureBase)
90
96
  }
91
97
  const result = await signer(create, "httpsig")
92
-
93
98
  if (!createCalled) {
94
99
  throw new Error(
95
100
  "create() must be invoked in order to construct the data to sign"
@@ -107,7 +112,6 @@ export const toHttpSigner = signer => {
107
112
  signatureInput,
108
113
  httpSigName(result.address)
109
114
  )
110
-
111
115
  const finalHeaders = {}
112
116
  for (const [key, value] of Object.entries(signedHeaders)) {
113
117
  if (key === "Signature" || key === "Signature-Input") {
@@ -1,6 +1,6 @@
1
1
  import base64url from "base64url"
2
2
  import crypto from "crypto"
3
- import { httpbis } from "http-message-signatures"
3
+ import { httpbis } from "./http-message-signatures/index.js"
4
4
  import { parseItem, serializeList } from "structured-headers"
5
5
  const {
6
6
  augmentHeaders,
package/esm/signer.js CHANGED
@@ -11,7 +11,7 @@ const joinUrl = ({ url, path }) => {
11
11
  : url + normalizedPath
12
12
  }
13
13
 
14
- export async function sign({ url, path, msg: encoded, jwk, signPath = false }) {
14
+ export async function sign({ url, path, msg: encoded, jwk, signPath = true }) {
15
15
  const signer = createSigner(jwk, url)
16
16
  const { body = null, ...headers } = encoded
17
17
  let _enc = { headers }
@@ -25,11 +25,12 @@ async function _sign({
25
25
  path,
26
26
  url,
27
27
  method = "POST",
28
- _path = false,
28
+ _path = true,
29
29
  }) {
30
30
  const headersObj = encoded ? encoded.headers : {}
31
31
  const body = encoded ? encoded.body : undefined
32
- const _url = joinUrl({ url, path })
32
+ let url_path = typeof _path === "string" ? _path : path
33
+ const _url = joinUrl({ url, path: url_path })
33
34
  headersObj["path"] = path
34
35
  if (body && !headersObj["content-length"]) {
35
36
  const bodySize = body.size || body.byteLength || 0
@@ -45,18 +46,12 @@ async function _sign({
45
46
  .split(",")
46
47
  .map(k => k.trim())
47
48
  : []
48
-
49
- const signingFields = Object.keys(lowercaseHeaders).filter(
50
- key => key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
51
- )
52
-
53
- if (_path) signingFields.push("path")
54
- /*
55
- if (signingFields.length === 0 && !body) {
56
- //lowercaseHeaders["content-length"] = "0"
57
- //signingFields.push("content-length")
58
- }
59
- */
49
+ let isPath = false
50
+ const signingFields = Object.keys(lowercaseHeaders).filter(key => {
51
+ if (key === "path") isPath = true
52
+ return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
53
+ })
54
+ if (_path !== false && isPath) signingFields.push("@path")
60
55
  const signedRequest = await toHttpSigner(signer)({
61
56
  request: { url: _url, method, headers: lowercaseHeaders },
62
57
  fields: signingFields,
@@ -85,7 +80,7 @@ export function signer(config) {
85
80
  if (!signer) throw new Error("Signer is required for mainnet mode")
86
81
  return async (
87
82
  fields,
88
- { encoded: _encoded = false, path: _path = false } = {}
83
+ { encoded: _encoded = false, path: _path = true } = {}
89
84
  ) => {
90
85
  const { path = "/relay/process", method = "POST", ...aoFields } = fields
91
86
  const encoded = _encoded ? aoFields : await enc(aoFields)
@@ -9,6 +9,6 @@
9
9
  "deploy": "node scripts/deploy.js"
10
10
  },
11
11
  "dependencies": {
12
- "wao": "^0.32.0"
12
+ "wao": "^0.32.2"
13
13
  }
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wao",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
4
4
  "bin": {
5
5
  "wao": "./cjs/cli.js",
6
6
  "wao-esm": "./esm/cli.js"
@@ -56,6 +56,7 @@
56
56
  "md5": "^2.3.0",
57
57
  "pm2": "^5.4.3",
58
58
  "ramda": "^0.30.1",
59
+ "structured-headers": "1.0.1",
59
60
  "warp-arbundles": "^1.0.4",
60
61
  "wasm-brotli": "^2.0.2",
61
62
  "yargs": "^17.7.2"