@bsv/sdk 1.8.6 → 1.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +21 -6
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/clients/AuthFetch.js +229 -13
  5. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +189 -0
  7. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  8. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  9. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  10. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +134 -0
  11. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/auth/Peer.js +21 -6
  14. package/dist/esm/src/auth/Peer.js.map +1 -1
  15. package/dist/esm/src/auth/clients/AuthFetch.js +229 -13
  16. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  17. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +187 -0
  18. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  19. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  20. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  21. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +109 -0
  22. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/src/auth/Peer.d.ts +1 -0
  25. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  26. package/dist/types/src/auth/clients/AuthFetch.d.ts +37 -0
  27. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  28. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +2 -0
  29. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +1 -0
  30. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts +6 -0
  31. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
  32. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +2 -0
  33. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +1 -0
  34. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  35. package/dist/umd/bundle.js +3 -3
  36. package/dist/umd/bundle.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/auth/Peer.ts +25 -18
  39. package/src/auth/__tests/Peer.test.ts +238 -1
  40. package/src/auth/clients/AuthFetch.ts +327 -18
  41. package/src/auth/clients/__tests__/AuthFetch.test.ts +262 -0
  42. package/src/auth/transports/SimplifiedFetchTransport.ts +185 -35
  43. package/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +126 -0
@@ -44,27 +44,37 @@ export class SimplifiedFetchTransport implements Transport {
44
44
  return await new Promise((resolve, reject) => {
45
45
  void (async () => {
46
46
  try {
47
- const responsePromise = this.fetchClient(`${this.baseUrl}/.well-known/auth`, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json'
51
- },
52
- body: JSON.stringify(message)
53
- })
47
+ const authUrl = `${this.baseUrl}/.well-known/auth`
48
+ const responsePromise = (async () => {
49
+ try {
50
+ return await this.fetchClient(authUrl, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json'
54
+ },
55
+ body: JSON.stringify(message)
56
+ })
57
+ } catch (error) {
58
+ throw this.createNetworkError(authUrl, error)
59
+ }
60
+ })()
54
61
 
55
62
  // For initialRequest message, mark connection as established and start pool.
56
63
  if (message.messageType !== 'initialRequest') {
57
64
  resolve()
58
65
  }
66
+
59
67
  const response = await responsePromise
60
- // Handle the response if data is received and callback is set
61
- if (response.ok && (this.onDataCallback != null)) {
68
+ if (!response.ok) {
69
+ const responseBodyArray = Array.from(new Uint8Array(await response.arrayBuffer()))
70
+ throw this.createUnauthenticatedResponseError(authUrl, response, responseBodyArray)
71
+ }
72
+
73
+ if (this.onDataCallback != null) {
62
74
  const responseMessage = await response.json()
63
75
  this.onDataCallback(responseMessage as AuthMessage)
64
- } else {
65
- // Server may be a non authenticated server
66
- throw new Error('HTTP server failed to authenticate')
67
76
  }
77
+
68
78
  if (message.messageType === 'initialRequest') {
69
79
  resolve()
70
80
  }
@@ -118,22 +128,39 @@ export class SimplifiedFetchTransport implements Transport {
118
128
  }
119
129
 
120
130
  // Send the actual fetch request to the server
121
- const response = await this.fetchClient(url, {
122
- method: httpRequestWithAuthHeaders.method,
123
- headers: httpRequestWithAuthHeaders.headers,
124
- body: httpRequestWithAuthHeaders.body
125
- })
131
+ let response: Response
132
+ try {
133
+ response = await this.fetchClient(url, {
134
+ method: httpRequestWithAuthHeaders.method,
135
+ headers: httpRequestWithAuthHeaders.headers,
136
+ body: httpRequestWithAuthHeaders.body
137
+ })
138
+ } catch (error) {
139
+ throw this.createNetworkError(url, error)
140
+ }
126
141
 
127
- // Check for an acceptable status
128
- if (response.status === 500 && (response.headers.get('x-bsv-auth-request-id') == null &&
129
- response.headers.get('x-bsv-auth-requested-certificates') == null)) {
130
- // Try parsing JSON error
131
- const errorInfo = await response.json()
132
- // Otherwise just throw whatever we got
133
- throw new Error(`HTTP ${response.status} - ${JSON.stringify(errorInfo)}`)
142
+ const responseBodyBuffer = await response.arrayBuffer()
143
+ const responseBodyArray = Array.from(new Uint8Array(responseBodyBuffer))
144
+
145
+ const missingAuthHeaders = ['x-bsv-auth-version', 'x-bsv-auth-identity-key', 'x-bsv-auth-signature']
146
+ .filter(headerName => {
147
+ const headerValue = response.headers.get(headerName)
148
+ return headerValue == null || headerValue.trim().length === 0
149
+ })
150
+
151
+ if (missingAuthHeaders.length > 0) {
152
+ throw this.createUnauthenticatedResponseError(url, response, responseBodyArray, missingAuthHeaders)
134
153
  }
135
154
 
136
- const parsedBody = await response.arrayBuffer()
155
+ const requestedCertificatesHeader = response.headers.get('x-bsv-auth-requested-certificates')
156
+ let requestedCertificates: RequestedCertificateSet | undefined
157
+ if (requestedCertificatesHeader != null) {
158
+ try {
159
+ requestedCertificates = JSON.parse(requestedCertificatesHeader) as RequestedCertificateSet
160
+ } catch (error) {
161
+ throw this.createMalformedHeaderError(url, 'x-bsv-auth-requested-certificates', requestedCertificatesHeader, error)
162
+ }
163
+ }
137
164
  const payloadWriter = new Utils.Writer()
138
165
  if (response.headers.get('x-bsv-auth-request-id') != null) {
139
166
  payloadWriter.write(Utils.toArray(response.headers.get('x-bsv-auth-request-id'), 'base64'))
@@ -171,12 +198,9 @@ export class SimplifiedFetchTransport implements Transport {
171
198
  }
172
199
 
173
200
  // Handle body
174
- if (parsedBody != null) {
175
- const bodyAsArray = Array.from(new Uint8Array(parsedBody))
176
- payloadWriter.writeVarIntNum(bodyAsArray.length)
177
- payloadWriter.write(bodyAsArray)
178
- } else {
179
- payloadWriter.writeVarIntNum(-1)
201
+ payloadWriter.writeVarIntNum(responseBodyArray.length)
202
+ if (responseBodyArray.length > 0) {
203
+ payloadWriter.write(responseBodyArray)
180
204
  }
181
205
 
182
206
  // Build the correct AuthMessage for the response
@@ -184,16 +208,16 @@ export class SimplifiedFetchTransport implements Transport {
184
208
  version: response.headers.get('x-bsv-auth-version'),
185
209
  messageType: response.headers.get('x-bsv-auth-message-type') === 'certificateRequest' ? 'certificateRequest' : 'general',
186
210
  identityKey: response.headers.get('x-bsv-auth-identity-key'),
187
- nonce: response.headers.get('x-bsv-auth-nonce'),
188
- yourNonce: response.headers.get('x-bsv-auth-your-nonce'),
189
- requestedCertificates: JSON.parse(response.headers.get('x-bsv-auth-requested-certificates')) as RequestedCertificateSet,
211
+ nonce: response.headers.get('x-bsv-auth-nonce') ?? undefined,
212
+ yourNonce: response.headers.get('x-bsv-auth-your-nonce') ?? undefined,
213
+ requestedCertificates,
190
214
  payload: payloadWriter.toArray(),
191
215
  signature: Utils.toArray(response.headers.get('x-bsv-auth-signature'), 'hex')
192
216
  }
193
217
 
194
218
  // If the server didn't provide the correct authentication headers, throw an error
195
219
  if (responseMessage.version == null) {
196
- throw new Error('HTTP server failed to authenticate')
220
+ throw this.createUnauthenticatedResponseError(url, response, responseBodyArray)
197
221
  }
198
222
 
199
223
  // Handle the response if data is received and callback is set
@@ -214,6 +238,132 @@ export class SimplifiedFetchTransport implements Transport {
214
238
  }
215
239
  }
216
240
 
241
+ private createNetworkError (url: string, originalError: unknown): Error {
242
+ const baseMessage = `Network error while sending authenticated request to ${url}`
243
+ if (originalError instanceof Error) {
244
+ const error = new Error(`${baseMessage}: ${originalError.message}`)
245
+ error.stack = originalError.stack
246
+ ;(error as any).cause = originalError
247
+ return error
248
+ }
249
+ return new Error(`${baseMessage}: ${String(originalError)}`)
250
+ }
251
+
252
+ private createUnauthenticatedResponseError (
253
+ url: string,
254
+ response: Response,
255
+ bodyBytes: number[],
256
+ missingHeaders: string[] = []
257
+ ): Error {
258
+ const statusText = (response.statusText ?? '').trim()
259
+ const statusDescription = statusText.length > 0
260
+ ? `${response.status} ${statusText}`
261
+ : `${response.status}`
262
+ const headerMessage = missingHeaders.length > 0
263
+ ? `missing headers: ${missingHeaders.join(', ')}`
264
+ : 'response lacked required BSV auth headers'
265
+ const bodyPreview = this.getBodyPreview(bodyBytes, response.headers.get('content-type'))
266
+ const parts = [`Received HTTP ${statusDescription} from ${url} without valid BSV authentication (${headerMessage})`]
267
+ if (bodyPreview != null) {
268
+ parts.push(`body preview: ${bodyPreview}`)
269
+ }
270
+
271
+ const error = new Error(parts.join(' - '))
272
+ ;(error as any).details = {
273
+ url,
274
+ status: response.status,
275
+ statusText: response.statusText,
276
+ missingHeaders,
277
+ bodyPreview
278
+ }
279
+ return error
280
+ }
281
+
282
+ private createMalformedHeaderError (
283
+ url: string,
284
+ headerName: string,
285
+ headerValue: string,
286
+ cause: unknown
287
+ ): Error {
288
+ const errorMessage = `Failed to parse ${headerName} returned by ${url}: ${headerValue}`
289
+ if (cause instanceof Error) {
290
+ const error = new Error(`${errorMessage}. ${cause.message}`)
291
+ error.stack = cause.stack
292
+ ;(error as any).cause = cause
293
+ return error
294
+ }
295
+ return new Error(`${errorMessage}. ${String(cause)}`)
296
+ }
297
+
298
+ private getBodyPreview (bodyBytes: number[], contentType: string | null): string | undefined {
299
+ if (bodyBytes.length === 0) {
300
+ return undefined
301
+ }
302
+
303
+ const maxBytesForPreview = 1024
304
+ const truncated = bodyBytes.length > maxBytesForPreview
305
+ const slice = truncated ? bodyBytes.slice(0, maxBytesForPreview) : bodyBytes
306
+ const isText = this.isTextualContent(contentType, slice)
307
+
308
+ let preview: string
309
+ if (isText) {
310
+ try {
311
+ preview = Utils.toUTF8(slice)
312
+ } catch {
313
+ preview = this.formatBinaryPreview(slice, truncated)
314
+ }
315
+ } else {
316
+ preview = this.formatBinaryPreview(slice, truncated)
317
+ }
318
+
319
+ if (preview.length > 512) {
320
+ preview = `${preview.slice(0, 512)}…`
321
+ }
322
+ if (truncated) {
323
+ preview = `${preview} (truncated)`
324
+ }
325
+ return preview
326
+ }
327
+
328
+ private isTextualContent (contentType: string | null, sample: number[]): boolean {
329
+ if (sample.length === 0) {
330
+ return false
331
+ }
332
+
333
+ if (contentType != null) {
334
+ const lowered = contentType.toLowerCase()
335
+ const textualTokens = [
336
+ 'application/json',
337
+ 'application/problem+json',
338
+ 'application/xml',
339
+ 'application/xhtml+xml',
340
+ 'application/javascript',
341
+ 'application/ecmascript',
342
+ 'application/x-www-form-urlencoded',
343
+ 'text/'
344
+ ]
345
+ if (textualTokens.some(token => lowered.includes(token)) || lowered.includes('charset=')) {
346
+ return true
347
+ }
348
+ }
349
+
350
+ const printableCount = sample.reduce((count, byte) => {
351
+ if (byte === 9 || byte === 10 || byte === 13) {
352
+ return count + 1
353
+ }
354
+ if (byte >= 32 && byte <= 126) {
355
+ return count + 1
356
+ }
357
+ return count
358
+ }, 0)
359
+ return (printableCount / sample.length) > 0.8
360
+ }
361
+
362
+ private formatBinaryPreview (bytes: number[], truncated: boolean): string {
363
+ const hex = bytes.map(byte => byte.toString(16).padStart(2, '0')).join('')
364
+ return `0x${hex}${truncated ? '…' : ''}`
365
+ }
366
+
217
367
  /**
218
368
  * Deserializes a request payload from a byte array into an HTTP request-like structure.
219
369
  *
@@ -0,0 +1,126 @@
1
+ import { jest } from '@jest/globals'
2
+ import { SimplifiedFetchTransport } from '../SimplifiedFetchTransport.js'
3
+ import * as Utils from '../../../primitives/utils.js'
4
+ import { AuthMessage } from '../../types.js'
5
+
6
+ function createGeneralPayload (path = '/resource', method = 'GET'): number[] {
7
+ const writer = new Utils.Writer()
8
+ const requestId = new Array(32).fill(1)
9
+ writer.write(requestId)
10
+
11
+ const methodBytes = Utils.toArray(method, 'utf8')
12
+ writer.writeVarIntNum(methodBytes.length)
13
+ writer.write(methodBytes)
14
+
15
+ const pathBytes = Utils.toArray(path, 'utf8')
16
+ writer.writeVarIntNum(pathBytes.length)
17
+ writer.write(pathBytes)
18
+
19
+ writer.writeVarIntNum(-1) // no query string
20
+ writer.writeVarIntNum(0) // no headers
21
+ writer.writeVarIntNum(-1) // no body
22
+
23
+ return writer.toArray()
24
+ }
25
+
26
+ function createGeneralMessage (overrides: Partial<AuthMessage> = {}): AuthMessage {
27
+ return {
28
+ version: '1.0',
29
+ messageType: 'general',
30
+ identityKey: 'client-key',
31
+ nonce: 'client-nonce',
32
+ yourNonce: 'server-nonce',
33
+ payload: createGeneralPayload(),
34
+ signature: new Array(64).fill(0),
35
+ ...overrides
36
+ }
37
+ }
38
+
39
+ afterEach(() => {
40
+ jest.restoreAllMocks()
41
+ })
42
+
43
+ describe('SimplifiedFetchTransport send', () => {
44
+ test('wraps network failures with context', async () => {
45
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
46
+ fetchMock.mockRejectedValue(new Error('network down'))
47
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
48
+ await transport.onData(async () => {})
49
+ const message = createGeneralMessage()
50
+
51
+ let caught: any
52
+ await expect((async () => {
53
+ try {
54
+ await transport.send(message)
55
+ } catch (error) {
56
+ caught = error
57
+ throw error
58
+ }
59
+ })()).rejects.toThrow('Network error while sending authenticated request to https://api.example.com/resource: network down')
60
+
61
+ expect(fetchMock).toHaveBeenCalledTimes(1)
62
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/resource')
63
+ expect(caught).toBeInstanceOf(Error)
64
+ expect(caught.cause).toBeInstanceOf(Error)
65
+ expect(caught.cause?.message).toBe('network down')
66
+ })
67
+
68
+ test('throws when server omits authentication headers', async () => {
69
+ const response = new Response('missing auth', {
70
+ status: 200,
71
+ headers: {
72
+ 'Content-Type': 'text/plain'
73
+ }
74
+ })
75
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
76
+ fetchMock.mockResolvedValue(response)
77
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
78
+ await transport.onData(async () => {})
79
+
80
+ const message = createGeneralMessage()
81
+
82
+ let thrown: any
83
+ await expect((async () => {
84
+ try {
85
+ await transport.send(message)
86
+ } catch (error) {
87
+ thrown = error
88
+ throw error
89
+ }
90
+ })()).rejects.toThrow('Received HTTP 200 from https://api.example.com/resource without valid BSV authentication (missing headers: x-bsv-auth-version, x-bsv-auth-identity-key, x-bsv-auth-signature)')
91
+
92
+ expect(thrown.details).toMatchObject({
93
+ url: 'https://api.example.com/resource',
94
+ status: 200,
95
+ missingHeaders: [
96
+ 'x-bsv-auth-version',
97
+ 'x-bsv-auth-identity-key',
98
+ 'x-bsv-auth-signature'
99
+ ]
100
+ })
101
+ expect(thrown.details.bodyPreview).toContain('missing auth')
102
+ })
103
+
104
+ test('rejects malformed requested certificates header', async () => {
105
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
106
+ fetchMock.mockResolvedValue(new Response('', {
107
+ status: 200,
108
+ headers: {
109
+ 'x-bsv-auth-version': '0.1',
110
+ 'x-bsv-auth-identity-key': 'server-key',
111
+ 'x-bsv-auth-signature': 'deadbeef',
112
+ 'x-bsv-auth-message-type': 'general',
113
+ 'x-bsv-auth-request-id': Utils.toBase64(new Array(32).fill(2)),
114
+ 'x-bsv-auth-requested-certificates': 'not-json'
115
+ }
116
+ }))
117
+
118
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
119
+ await transport.onData(async () => {})
120
+ const message = createGeneralMessage()
121
+
122
+ await expect(transport.send(message)).rejects.toThrow(
123
+ 'Failed to parse x-bsv-auth-requested-certificates returned by https://api.example.com/resource: not-json'
124
+ )
125
+ })
126
+ })