@bsv/sdk 1.3.27 → 1.3.29

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 (32) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +142 -91
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/SessionManager.js +82 -21
  5. package/dist/cjs/src/auth/SessionManager.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/AuthFetch.js +6 -2
  7. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/auth/Peer.js +138 -88
  10. package/dist/esm/src/auth/Peer.js.map +1 -1
  11. package/dist/esm/src/auth/SessionManager.js +88 -22
  12. package/dist/esm/src/auth/SessionManager.js.map +1 -1
  13. package/dist/esm/src/auth/clients/AuthFetch.js +6 -2
  14. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/auth/Peer.d.ts +21 -23
  17. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  18. package/dist/types/src/auth/SessionManager.d.ts +25 -7
  19. package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
  20. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  21. package/dist/types/src/auth/types.d.ts +1 -0
  22. package/dist/types/src/auth/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  24. package/dist/umd/bundle.js +1 -1
  25. package/docs/auth.md +37 -42
  26. package/package.json +1 -1
  27. package/src/auth/Peer.ts +186 -130
  28. package/src/auth/SessionManager.ts +89 -22
  29. package/src/auth/__tests/Peer.test.ts +66 -0
  30. package/src/auth/__tests/SessionManager.test.ts +3 -2
  31. package/src/auth/clients/AuthFetch.ts +6 -2
  32. package/src/auth/types.ts +1 -0
@@ -1,55 +1,112 @@
1
1
  import { PeerSession } from './types.js'
2
2
 
3
3
  /**
4
- * Manages sessions for peers, allowing sessions to be added, retrieved, updated, and removed
5
- * by relevant identifiers (sessionNonce and peerIdentityKey).
4
+ * Manages sessions for peers, allowing multiple concurrent sessions
5
+ * per identity key. Primary lookup is always by `sessionNonce`.
6
6
  */
7
7
  export class SessionManager {
8
- private readonly identifierToSession: Map<string, PeerSession>
8
+ /**
9
+ * Maps sessionNonce -> PeerSession
10
+ */
11
+ private readonly sessionNonceToSession: Map<string, PeerSession>
12
+
13
+ /**
14
+ * Maps identityKey -> Set of sessionNonces
15
+ */
16
+ private readonly identityKeyToNonces: Map<string, Set<string>>
9
17
 
10
18
  constructor () {
11
- this.identifierToSession = new Map<string, PeerSession>()
19
+ this.sessionNonceToSession = new Map<string, PeerSession>()
20
+ this.identityKeyToNonces = new Map<string, Set<string>>()
12
21
  }
13
22
 
14
23
  /**
15
- * Adds a session to the manager, associating it with relevant identifiers for retrieval.
24
+ * Adds a session to the manager, associating it with its sessionNonce,
25
+ * and also with its peerIdentityKey (if any).
26
+ *
27
+ * This does NOT overwrite existing sessions for the same peerIdentityKey,
28
+ * allowing multiple concurrent sessions for the same peer.
16
29
  *
17
30
  * @param {PeerSession} session - The peer session to add.
18
31
  */
19
32
  addSession (session: PeerSession): void {
20
- if ((session.sessionNonce === null || session.sessionNonce === undefined || session.sessionNonce === '') &&
21
- (session.peerIdentityKey === null || session.peerIdentityKey === undefined || session.peerIdentityKey === '')) {
33
+ if (typeof session.sessionNonce !== 'string') {
22
34
  throw new Error(
23
- 'Invalid session: at least one of sessionNonce or peerIdentityKey is required.'
35
+ 'Invalid session: sessionNonce is required to add a session.'
24
36
  )
25
37
  }
26
38
 
27
- if (session.sessionNonce !== null && session.sessionNonce !== undefined && session.sessionNonce !== '') {
28
- this.identifierToSession.set(session.sessionNonce, session)
29
- }
30
- if (session.peerIdentityKey !== null && session.peerIdentityKey !== undefined && session.peerIdentityKey !== '') {
31
- this.identifierToSession.set(session.peerIdentityKey, session)
39
+ // Use the sessionNonce as the primary key
40
+ this.sessionNonceToSession.set(session.sessionNonce, session)
41
+
42
+ // Also track it by identity key if present
43
+ if (typeof session.peerIdentityKey === 'string') {
44
+ let nonces = this.identityKeyToNonces.get(session.peerIdentityKey)
45
+ if (nonces == null) {
46
+ nonces = new Set<string>()
47
+ this.identityKeyToNonces.set(session.peerIdentityKey, nonces)
48
+ }
49
+ nonces.add(session.sessionNonce)
32
50
  }
33
51
  }
34
52
 
35
53
  /**
36
- * Updates a session in the manager, ensuring that all identifiers are correctly associated.
54
+ * Updates a session in the manager (primarily by re-adding it),
55
+ * ensuring we record the latest data (e.g., isAuthenticated, lastUpdate, etc.).
37
56
  *
38
57
  * @param {PeerSession} session - The peer session to update.
39
58
  */
40
59
  updateSession (session: PeerSession): void {
60
+ // Remove the old references (if any) and re-add
41
61
  this.removeSession(session)
42
62
  this.addSession(session)
43
63
  }
44
64
 
45
65
  /**
46
- * Retrieves a session based on a given identifier.
66
+ * Retrieves a session based on a given identifier, which can be:
67
+ * - A sessionNonce, or
68
+ * - A peerIdentityKey.
69
+ *
70
+ * If it is a `sessionNonce`, returns that exact session.
71
+ * If it is a `peerIdentityKey`, returns the "best" (e.g. most recently updated,
72
+ * authenticated) session associated with that peer, if any.
47
73
  *
48
74
  * @param {string} identifier - The identifier for the session (sessionNonce or peerIdentityKey).
49
75
  * @returns {PeerSession | undefined} - The matching peer session, or undefined if not found.
50
76
  */
51
77
  getSession (identifier: string): PeerSession | undefined {
52
- return this.identifierToSession.get(identifier)
78
+ // Check if this identifier is directly a sessionNonce
79
+ const direct = this.sessionNonceToSession.get(identifier)
80
+ if (direct != null) {
81
+ return direct
82
+ }
83
+
84
+ // Otherwise, interpret the identifier as an identity key
85
+ const nonces = this.identityKeyToNonces.get(identifier)
86
+ if ((nonces == null) || nonces.size === 0) {
87
+ return undefined
88
+ }
89
+
90
+ // Pick the "best" session. One sensible approach:
91
+ // - Choose an authenticated session if available
92
+ // - Among them, pick the most recently updated
93
+ let best: PeerSession | undefined
94
+ for (const nonce of nonces) {
95
+ const s = this.sessionNonceToSession.get(nonce)
96
+ if (s == null) continue
97
+ // We can prefer authenticated sessions
98
+ if (best == null) {
99
+ best = s
100
+ } else {
101
+ // If we want the "most recently updated" AND isAuthenticated
102
+ if ((s.lastUpdate ?? 0) > (best.lastUpdate ?? 0)) {
103
+ best = s
104
+ }
105
+ }
106
+ }
107
+ // Optionally, you could also filter out isAuthenticated===false if you only want
108
+ // an authenticated session. But for our usage, let's return the latest any session.
109
+ return best
53
110
  }
54
111
 
55
112
  /**
@@ -58,21 +115,31 @@ export class SessionManager {
58
115
  * @param {PeerSession} session - The peer session to remove.
59
116
  */
60
117
  removeSession (session: PeerSession): void {
61
- if (session.sessionNonce !== null && session.sessionNonce !== undefined && session.sessionNonce !== '') {
62
- this.identifierToSession.delete(session.sessionNonce)
118
+ if (typeof session.sessionNonce === 'string') {
119
+ this.sessionNonceToSession.delete(session.sessionNonce)
63
120
  }
64
- if (session.peerIdentityKey !== null && session.peerIdentityKey !== undefined && session.peerIdentityKey !== '') {
65
- this.identifierToSession.delete(session.peerIdentityKey)
121
+ if (typeof session.peerIdentityKey === 'string') {
122
+ const nonces = this.identityKeyToNonces.get(session.peerIdentityKey)
123
+ if (nonces != null) {
124
+ nonces.delete(session.sessionNonce ?? '')
125
+ if (nonces.size === 0) {
126
+ this.identityKeyToNonces.delete(session.peerIdentityKey)
127
+ }
128
+ }
66
129
  }
67
130
  }
68
131
 
69
132
  /**
70
- * Checks if a session exists based on a given identifier.
133
+ * Checks if a session exists for a given identifier (either sessionNonce or identityKey).
71
134
  *
72
135
  * @param {string} identifier - The identifier to check.
73
136
  * @returns {boolean} - True if the session exists, false otherwise.
74
137
  */
75
138
  hasSession (identifier: string): boolean {
76
- return this.identifierToSession.has(identifier)
139
+ const direct = this.sessionNonceToSession.has(identifier)
140
+ if (direct) return true
141
+ // if not directly a nonce, interpret as identityKey
142
+ const nonces = this.identityKeyToNonces.get(identifier)
143
+ return !(nonces == null) && nonces.size > 0
77
144
  }
78
145
  }
@@ -232,6 +232,72 @@ describe('Peer class mutual authentication and certificate exchange', () => {
232
232
  expect(certificatesReceivedByBob).toEqual([])
233
233
  }, 15000)
234
234
 
235
+ it('Alice talks to Bob across two devices, Bob can respond across both sessions', async () => {
236
+ const transportA1 = new LocalTransport()
237
+ const transportA2 = new LocalTransport()
238
+ const transportB = new LocalTransport()
239
+ transportA1.connect(transportB)
240
+ const aliceKey = PrivateKey.fromRandom()
241
+ const walletA1 = new CompletedProtoWallet(aliceKey)
242
+ const walletA2 = new CompletedProtoWallet(aliceKey)
243
+ const walletB = new CompletedProtoWallet(PrivateKey.fromRandom())
244
+ const aliceFirstDevice = new Peer(
245
+ walletA1,
246
+ transportA1
247
+ )
248
+ const aliceOtherDevice = new Peer(
249
+ walletA2,
250
+ transportA2
251
+ )
252
+ const bob = new Peer(
253
+ walletB,
254
+ transportB
255
+ )
256
+ const alice1MessageHandler = jest.fn()
257
+ const alice2MessageHandler = jest.fn()
258
+ const bobMessageHandler = jest.fn()
259
+
260
+ const bobReceivedGeneralMessage = new Promise<void>((resolve) => {
261
+ bob.listenForGeneralMessages((senderPublicKey, payload) => {
262
+ (async () => {
263
+ console.log('Bob 1 received message:', Utils.toUTF8(payload))
264
+ await bob.toPeer(Utils.toArray('Hello Alice!'), senderPublicKey)
265
+ resolve()
266
+ bobMessageHandler(senderPublicKey, payload)
267
+ })().catch(e => console.log(e))
268
+ })
269
+ })
270
+ let aliceReceivedGeneralMessageOnFirstDevice = new Promise<void>((resolve) => {
271
+ aliceFirstDevice.listenForGeneralMessages((senderPublicKey, payload) => {
272
+ (async () => {
273
+ console.log('Alice 1 received message:', Utils.toUTF8(payload))
274
+ resolve()
275
+ alice1MessageHandler(senderPublicKey, payload)
276
+ })().catch(e => console.log(e))
277
+ })
278
+ })
279
+ const aliceReceivedGeneralMessageOnOtherDevice = new Promise<void>((resolve) => {
280
+ aliceOtherDevice.listenForGeneralMessages((senderPublicKey, payload) => {
281
+ (async () => {
282
+ console.log('Alice 2 received message:', Utils.toUTF8(payload))
283
+ resolve()
284
+ alice2MessageHandler(senderPublicKey, payload)
285
+ })().catch(e => console.log(e))
286
+ })
287
+ })
288
+
289
+ await aliceFirstDevice.toPeer(Utils.toArray('Hello Bob!'))
290
+ await bobReceivedGeneralMessage
291
+ await aliceReceivedGeneralMessageOnFirstDevice
292
+ transportA2.connect(transportB)
293
+ await aliceOtherDevice.toPeer(Utils.toArray('Hello Bob from my other device!'))
294
+ await aliceReceivedGeneralMessageOnOtherDevice
295
+ transportA1.connect(transportB)
296
+ await aliceFirstDevice.toPeer(Utils.toArray('Back on my first device now, Bob! Can you still hear me?'))
297
+ await new Promise(resolve => setTimeout(resolve, 2000))
298
+ expect(alice1MessageHandler.mock.calls.length).toEqual(2)
299
+ }, 30000)
300
+
235
301
  it('Bob requests certificates from Alice, Alice does not request any from Bob', async () => {
236
302
  const alicePubKey = (await walletA.getPublicKey({ identityKey: true }))
237
303
  .publicKey
@@ -10,7 +10,8 @@ describe('SessionManager', () => {
10
10
  validSession = {
11
11
  isAuthenticated: false,
12
12
  sessionNonce: 'testSessionNonce',
13
- peerIdentityKey: 'testPeerIdentityKey'
13
+ peerIdentityKey: 'testPeerIdentityKey',
14
+ lastUpdate: 1
14
15
  }
15
16
  })
16
17
 
@@ -39,7 +40,7 @@ describe('SessionManager', () => {
39
40
  }
40
41
 
41
42
  expect(() => sessionManager.addSession(invalidSession)).toThrow(
42
- 'Invalid session: at least one of sessionNonce or peerIdentityKey is required.'
43
+ 'Invalid session: sessionNonce is required to add a session.'
43
44
  )
44
45
  })
45
46
 
@@ -405,7 +405,7 @@ export class AuthFetch {
405
405
  }
406
406
 
407
407
  const derivationPrefix = originalResponse.headers.get('x-bsv-payment-derivation-prefix')
408
- if (!derivationPrefix) {
408
+ if (typeof derivationPrefix !== 'string' || derivationPrefix.length < 1) {
409
409
  throw new Error('Missing x-bsv-payment-derivation-prefix response header.')
410
410
  }
411
411
 
@@ -426,8 +426,12 @@ export class AuthFetch {
426
426
  outputs: [{
427
427
  satoshis: satoshisRequired,
428
428
  lockingScript,
429
+ customInstructions: JSON.stringify({ derivationPrefix, derivationSuffix, payee: serverIdentityKey }),
429
430
  outputDescription: 'HTTP request payment'
430
- }]
431
+ }],
432
+ options: {
433
+ randomizeOutputs: false
434
+ }
431
435
  })
432
436
 
433
437
  // Attach the payment to the request headers
package/src/auth/types.ts CHANGED
@@ -38,4 +38,5 @@ export interface PeerSession {
38
38
  sessionNonce?: string
39
39
  peerNonce?: string
40
40
  peerIdentityKey?: string
41
+ lastUpdate: number
41
42
  }