@bsv/message-box-client 1.1.9 → 1.1.11

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 (36) hide show
  1. package/dist/cjs/package.json +5 -5
  2. package/dist/cjs/src/MessageBoxClient.js +707 -87
  3. package/dist/cjs/src/MessageBoxClient.js.map +1 -1
  4. package/dist/cjs/src/PeerPayClient.js +61 -28
  5. package/dist/cjs/src/PeerPayClient.js.map +1 -1
  6. package/dist/cjs/src/Utils/logger.js +22 -21
  7. package/dist/cjs/src/Utils/logger.js.map +1 -1
  8. package/dist/cjs/src/types/permissions.js +6 -0
  9. package/dist/cjs/src/types/permissions.js.map +1 -0
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/src/MessageBoxClient.js +593 -12
  12. package/dist/esm/src/MessageBoxClient.js.map +1 -1
  13. package/dist/esm/src/PeerPayClient.js +1 -1
  14. package/dist/esm/src/PeerPayClient.js.map +1 -1
  15. package/dist/esm/src/Utils/logger.js +17 -19
  16. package/dist/esm/src/Utils/logger.js.map +1 -1
  17. package/dist/esm/src/types/permissions.js +5 -0
  18. package/dist/esm/src/types/permissions.js.map +1 -0
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/src/MessageBoxClient.d.ts +218 -13
  21. package/dist/types/src/MessageBoxClient.d.ts.map +1 -1
  22. package/dist/types/src/PeerPayClient.d.ts.map +1 -1
  23. package/dist/types/src/Utils/logger.d.ts +5 -8
  24. package/dist/types/src/Utils/logger.d.ts.map +1 -1
  25. package/dist/types/src/types/permissions.d.ts +75 -0
  26. package/dist/types/src/types/permissions.d.ts.map +1 -0
  27. package/dist/types/src/types.d.ts +71 -2
  28. package/dist/types/src/types.d.ts.map +1 -1
  29. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  30. package/dist/umd/bundle.js +1 -1
  31. package/package.json +5 -5
  32. package/src/MessageBoxClient.ts +732 -24
  33. package/src/PeerPayClient.ts +11 -11
  34. package/src/Utils/logger.ts +17 -19
  35. package/src/types/permissions.ts +81 -0
  36. package/src/types.ts +77 -2
@@ -30,25 +30,31 @@ import {
30
30
  Utils,
31
31
  Transaction,
32
32
  PushDrop,
33
- BEEF,
34
- LockingScript,
35
- HexString
33
+ PubKeyHex,
34
+ createNonce,
35
+ P2PKH,
36
+ PublicKey,
37
+ CreateActionOutput,
38
+ WalletInterface,
39
+ ProtoWallet,
40
+ InternalizeOutput,
41
+ Random
36
42
  } from '@bsv/sdk'
37
43
  import { AuthSocketClient } from '@bsv/authsocket-client'
38
- import { Logger } from './Utils/logger.js'
39
- import { AcknowledgeMessageParams, EncryptedMessage, ListMessagesParams, MessageBoxClientOptions, PeerMessage, SendMessageParams, SendMessageResponse } from './types.js'
44
+ import * as Logger from './Utils/logger.js'
45
+ import { AcknowledgeMessageParams, AdvertisementToken, EncryptedMessage, ListMessagesParams, MessageBoxClientOptions, Payment, PeerMessage, SendMessageParams, SendMessageResponse, DeviceRegistrationParams, DeviceRegistrationResponse, RegisteredDevice, ListDevicesResponse } from './types.js'
46
+ import {
47
+ SetMessageBoxPermissionParams,
48
+ GetMessageBoxPermissionParams,
49
+ MessageBoxPermission,
50
+ MessageBoxQuote,
51
+ ListPermissionsParams,
52
+ GetQuoteParams
53
+ } from './types/permissions.js'
40
54
 
41
55
  const DEFAULT_MAINNET_HOST = 'https://messagebox.babbage.systems'
42
56
  const DEFAULT_TESTNET_HOST = 'https://staging-messagebox.babbage.systems'
43
57
 
44
- interface AdvertisementToken {
45
- host: string
46
- txid: HexString
47
- outputIndex: number
48
- lockingScript: LockingScript
49
- beef: BEEF
50
- }
51
-
52
58
  /**
53
59
  * @class MessageBoxClient
54
60
  * @description
@@ -79,7 +85,7 @@ interface AdvertisementToken {
79
85
  export class MessageBoxClient {
80
86
  private host: string
81
87
  public readonly authFetch: AuthFetch
82
- private readonly walletClient: WalletClient
88
+ private readonly walletClient: WalletInterface
83
89
  private socket?: ReturnType<typeof AuthSocketClient>
84
90
  private myIdentityKey?: string
85
91
  private readonly joinedRooms: Set<string> = new Set()
@@ -91,7 +97,7 @@ export class MessageBoxClient {
91
97
  * @constructor
92
98
  * @param {Object} options - Initialization options for the MessageBoxClient.
93
99
  * @param {string} [options.host] - The base URL of the MessageBox server. If omitted, defaults to mainnet/testnet hosts.
94
- * @param {WalletClient} options.walletClient - Wallet instance used for authentication, signing, and encryption.
100
+ * @param {WalletInterface} options.walletClient - Wallet instance used for authentication, signing, and encryption.
95
101
  * @param {boolean} [options.enableLogging=false] - Whether to enable detailed debug logging to the console.
96
102
  * @param {'local' | 'mainnet' | 'testnet'} [options.networkPreset='mainnet'] - Overlay network preset used for routing and advertisement lookup.
97
103
  *
@@ -413,7 +419,7 @@ export class MessageBoxClient {
413
419
  query
414
420
  })
415
421
  if (result.type !== 'output-list') {
416
- throw new Error(`Unexpected result type: ${result.type}`)
422
+ throw new Error(`Unexpected result type: ${String(result.type)}`)
417
423
  }
418
424
 
419
425
  for (const output of result.outputs) {
@@ -623,7 +629,8 @@ export class MessageBoxClient {
623
629
  messageBox,
624
630
  body,
625
631
  messageId,
626
- skipEncryption
632
+ skipEncryption,
633
+ checkPermissions
627
634
  }: SendMessageParams): Promise<SendMessageResponse> {
628
635
  await this.assertInitialized()
629
636
  if (recipient == null || recipient.trim() === '') {
@@ -701,7 +708,8 @@ export class MessageBoxClient {
701
708
  messageBox,
702
709
  body,
703
710
  messageId: finalMessageId,
704
- skipEncryption
711
+ skipEncryption,
712
+ checkPermissions
705
713
  }
706
714
 
707
715
  this.resolveHostForRecipient(recipient)
@@ -743,7 +751,8 @@ export class MessageBoxClient {
743
751
  messageBox,
744
752
  body,
745
753
  messageId: finalMessageId,
746
- skipEncryption
754
+ skipEncryption,
755
+ checkPermissions
747
756
  }
748
757
 
749
758
  this.resolveHostForRecipient(recipient)
@@ -857,6 +866,43 @@ export class MessageBoxClient {
857
866
  throw new Error('Every message must have a body!')
858
867
  }
859
868
 
869
+ // Optional permission checking for backwards compatibility
870
+ let paymentData: Payment | undefined
871
+ if (message.checkPermissions === true) {
872
+ try {
873
+ Logger.log('[MB CLIENT] Checking permissions and fees for message...')
874
+
875
+ // Get quote to check if payment is required
876
+ const quote = await this.getMessageBoxQuote({
877
+ recipient: message.recipient,
878
+ messageBox: message.messageBox
879
+ })
880
+
881
+ if (quote.recipientFee === -1) {
882
+ throw new Error('You have been blocked from sending messages to this recipient.')
883
+ }
884
+
885
+ if (quote.recipientFee > 0 || quote.deliveryFee > 0) {
886
+ const requiredPayment = quote.recipientFee + quote.deliveryFee
887
+
888
+ if (requiredPayment > 0) {
889
+ Logger.log(`[MB CLIENT] Creating payment of ${requiredPayment} sats for message...`)
890
+
891
+ // Create payment using helper method
892
+ paymentData = await this.createMessagePayment(
893
+ message.recipient,
894
+ quote,
895
+ overrideHost
896
+ )
897
+
898
+ Logger.log('[MB CLIENT] Payment data prepared:', paymentData)
899
+ }
900
+ }
901
+ } catch (error) {
902
+ throw new Error(`Permission check failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
903
+ }
904
+ }
905
+
860
906
  let messageId: string
861
907
  try {
862
908
  const hmac = await this.walletClient.createHmac({
@@ -890,7 +936,8 @@ export class MessageBoxClient {
890
936
  ...message,
891
937
  messageId,
892
938
  body: finalBody
893
- }
939
+ },
940
+ ...(paymentData != null && { payment: paymentData })
894
941
  }
895
942
 
896
943
  try {
@@ -1132,9 +1179,16 @@ export class MessageBoxClient {
1132
1179
  *
1133
1180
  * Each message is:
1134
1181
  * - Parsed and, if encrypted, decrypted using AES-256-GCM via BRC-2-compliant ECDH key derivation and symmetric encryption.
1182
+ * - Automatically processed for payments: if the message includes recipient fee payments, they are internalized using `walletClient.internalizeAction()`.
1135
1183
  * - Returned as a normalized `PeerMessage` with readable string body content.
1136
1184
  *
1137
- * Decryption automatically derives a shared secret using the sender’s identity key and the receiver’s child private key.
1185
+ * Payment Processing:
1186
+ * - Detects messages that include payment data (from paid message delivery).
1187
+ * - Automatically internalizes recipient payment outputs, allowing you to receive payments without additional API calls.
1188
+ * - Only recipient payments are stored with messages - delivery fees are already processed by the server.
1189
+ * - Continues processing messages even if payment internalization fails.
1190
+ *
1191
+ * Decryption automatically derives a shared secret using the sender's identity key and the receiver's child private key.
1138
1192
  * If the sender is the same as the recipient, the `counterparty` is set to `'self'`.
1139
1193
  *
1140
1194
  * @throws {Error} If no messageBox is specified, the request fails, or the server returns an error.
@@ -1142,6 +1196,7 @@ export class MessageBoxClient {
1142
1196
  * @example
1143
1197
  * const messages = await client.listMessages({ messageBox: 'inbox' })
1144
1198
  * messages.forEach(msg => console.log(msg.sender, msg.body))
1199
+ * // Payments included with messages are automatically received
1145
1200
  */
1146
1201
  async listMessages({ messageBox, host }: ListMessagesParams): Promise<PeerMessage[]> {
1147
1202
  await this.assertInitialized()
@@ -1219,10 +1274,70 @@ export class MessageBoxClient {
1219
1274
  const parsedBody: unknown =
1220
1275
  typeof message.body === 'string' ? tryParse(message.body) : message.body
1221
1276
 
1277
+ let messageContent: any = parsedBody
1278
+ let paymentData: Payment | undefined
1279
+
1222
1280
  if (
1223
1281
  parsedBody != null &&
1224
1282
  typeof parsedBody === 'object' &&
1225
- typeof (parsedBody as any).encryptedMessage === 'string'
1283
+ 'message' in parsedBody
1284
+ ) {
1285
+ messageContent = (parsedBody as any).message?.body
1286
+ paymentData = (parsedBody as any).payment
1287
+ }
1288
+
1289
+ // Process payment if present - server now only stores recipient payments
1290
+ if (paymentData?.tx != null && paymentData.outputs != null) {
1291
+ try {
1292
+ Logger.log(
1293
+ `[MB CLIENT] Processing recipient payment in message from ${String(message.sender)}…`
1294
+ )
1295
+
1296
+ // All outputs in the stored payment data are for the recipient
1297
+ // (delivery fees are already processed by the server)
1298
+ const recipientOutputs = paymentData.outputs.filter(
1299
+ output => output.protocol === 'wallet payment'
1300
+ )
1301
+
1302
+ if (recipientOutputs.length > 0) {
1303
+ Logger.log(
1304
+ `[MB CLIENT] Internalizing ${recipientOutputs.length} recipient payment output(s)…`
1305
+ )
1306
+
1307
+ const internalizeResult = await this.walletClient.internalizeAction({
1308
+ tx: paymentData.tx,
1309
+ outputs: recipientOutputs,
1310
+ description: paymentData.description ?? 'MessageBox recipient payment'
1311
+ })
1312
+
1313
+ if (internalizeResult.accepted) {
1314
+ Logger.log(
1315
+ '[MB CLIENT] Successfully internalized recipient payment'
1316
+ )
1317
+ } else {
1318
+ Logger.warn(
1319
+ '[MB CLIENT] Recipient payment internalization was not accepted'
1320
+ )
1321
+ }
1322
+ } else {
1323
+ Logger.log(
1324
+ '[MB CLIENT] No wallet payment outputs found in payment data'
1325
+ )
1326
+ }
1327
+ } catch (paymentError) {
1328
+ Logger.error(
1329
+ '[MB CLIENT ERROR] Failed to internalize recipient payment:',
1330
+ paymentError
1331
+ )
1332
+ // Continue processing the message even if payment fails
1333
+ }
1334
+ }
1335
+
1336
+ // Handle message decryption
1337
+ if (
1338
+ messageContent != null &&
1339
+ typeof messageContent === 'object' &&
1340
+ typeof (messageContent as any).encryptedMessage === 'string'
1226
1341
  ) {
1227
1342
  Logger.log(
1228
1343
  `[MB CLIENT] Decrypting message from ${String(message.sender)}…`
@@ -1233,7 +1348,7 @@ export class MessageBoxClient {
1233
1348
  keyID: '1',
1234
1349
  counterparty: message.sender,
1235
1350
  ciphertext: Utils.toArray(
1236
- (parsedBody as any).encryptedMessage,
1351
+ (messageContent as any).encryptedMessage,
1237
1352
  'base64'
1238
1353
  )
1239
1354
  })
@@ -1241,7 +1356,10 @@ export class MessageBoxClient {
1241
1356
  const decryptedText = Utils.toUTF8(decrypted.plaintext)
1242
1357
  message.body = tryParse(decryptedText)
1243
1358
  } else {
1244
- message.body = parsedBody as string | Record<string, any>
1359
+ // Handle both old format (direct content) and new format (message.body)
1360
+ message.body = messageContent != null
1361
+ ? (typeof messageContent === 'string' ? messageContent : messageContent)
1362
+ : (parsedBody as string | Record<string, any>)
1245
1363
  }
1246
1364
  } catch (err) {
1247
1365
  Logger.error(
@@ -1338,4 +1456,594 @@ export class MessageBoxClient {
1338
1456
  `Failed to acknowledge messages on all hosts: ${errs.map(e => String(e)).join('; ')}`
1339
1457
  )
1340
1458
  }
1459
+
1460
+ // ===========================
1461
+ // PERMISSION MANAGEMENT METHODS
1462
+ // ===========================
1463
+
1464
+ /**
1465
+ * @method setMessageBoxPermission
1466
+ * @async
1467
+ * @param {SetMessageBoxPermissionParams} params - Permission configuration
1468
+ * @param {string} [overrideHost] - Optional host override
1469
+ * @returns {Promise<void>} Permission status after setting
1470
+ *
1471
+ * @description
1472
+ * Sets permission for receiving messages in a specific messageBox.
1473
+ * Can set sender-specific permissions or box-wide defaults.
1474
+ *
1475
+ * @example
1476
+ * // Set box-wide default: allow notifications for 10 sats
1477
+ * await client.setMessageBoxPermission({ messageBox: 'notifications', recipientFee: 10 })
1478
+ *
1479
+ * // Block specific sender
1480
+ * await client.setMessageBoxPermission({
1481
+ * messageBox: 'notifications',
1482
+ * sender: '03abc123...',
1483
+ * recipientFee: -1
1484
+ * })
1485
+ */
1486
+ async setMessageBoxPermission(
1487
+ params: SetMessageBoxPermissionParams,
1488
+ overrideHost?: string
1489
+ ): Promise<void> {
1490
+ await this.assertInitialized()
1491
+ const finalHost = overrideHost ?? this.host
1492
+
1493
+ Logger.log('[MB CLIENT] Setting messageBox permission...')
1494
+
1495
+ const response = await this.authFetch.fetch(`${finalHost}/permissions/set`, {
1496
+ method: 'POST',
1497
+ headers: { 'Content-Type': 'application/json' },
1498
+ body: JSON.stringify({
1499
+ messageBox: params.messageBox,
1500
+ recipientFee: params.recipientFee,
1501
+ ...(params.sender != null && { sender: params.sender })
1502
+ })
1503
+ })
1504
+
1505
+ if (!response.ok) {
1506
+ const errorData = await response.json().catch(() => ({}))
1507
+ throw new Error(`Failed to set permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`)
1508
+ }
1509
+
1510
+ const { status, description } = await response.json()
1511
+ if (status === 'error') {
1512
+ throw new Error(description ?? 'Failed to set permission')
1513
+ }
1514
+ }
1515
+
1516
+ /**
1517
+ * @method getMessageBoxPermission
1518
+ * @async
1519
+ * @param {GetMessageBoxPermissionParams} params - Permission query parameters
1520
+ * @param {string} [overrideHost] - Optional host override
1521
+ * @returns {Promise<MessageBoxPermission | null>} Permission data (null if not set)
1522
+ *
1523
+ * @description
1524
+ * Gets current permission data for a sender/messageBox combination.
1525
+ * Returns null if no permission is set.
1526
+ *
1527
+ * @example
1528
+ * const status = await client.getMessageBoxPermission({
1529
+ * recipient: '03def456...',
1530
+ * messageBox: 'notifications',
1531
+ * sender: '03abc123...'
1532
+ * })
1533
+ */
1534
+ async getMessageBoxPermission(
1535
+ params: GetMessageBoxPermissionParams,
1536
+ overrideHost?: string
1537
+ ): Promise<MessageBoxPermission | null> {
1538
+ await this.assertInitialized()
1539
+
1540
+ const finalHost = overrideHost ?? await this.resolveHostForRecipient(params.recipient)
1541
+ const queryParams = new URLSearchParams({
1542
+ recipient: params.recipient,
1543
+ messageBox: params.messageBox,
1544
+ ...(params.sender != null && { sender: params.sender })
1545
+ })
1546
+
1547
+ Logger.log('[MB CLIENT] Getting messageBox permission...')
1548
+
1549
+ const response = await this.authFetch.fetch(`${finalHost}/permissions/get?${queryParams.toString()}`, {
1550
+ method: 'GET'
1551
+ })
1552
+
1553
+ if (!response.ok) {
1554
+ const errorData = await response.json().catch(() => ({}))
1555
+ throw new Error(`Failed to get permission: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`)
1556
+ }
1557
+
1558
+ const data = await response.json()
1559
+ if (data.status === 'error') {
1560
+ throw new Error(data.description ?? 'Failed to get permission')
1561
+ }
1562
+
1563
+ return data.permission
1564
+ }
1565
+
1566
+ /**
1567
+ * @method getMessageBoxQuote
1568
+ * @async
1569
+ * @param {GetQuoteParams} params - Quote request parameters
1570
+ * @returns {Promise<MessageBoxQuote>} Fee quote and permission status
1571
+ *
1572
+ * @description
1573
+ * Gets a fee quote for sending a message, including delivery and recipient fees.
1574
+ *
1575
+ * @example
1576
+ * const quote = await client.getMessageBoxQuote({
1577
+ * recipient: '03def456...',
1578
+ * messageBox: 'notifications'
1579
+ * })
1580
+ */
1581
+ async getMessageBoxQuote(params: GetQuoteParams, overrideHost?: string): Promise<MessageBoxQuote> {
1582
+ await this.assertInitialized()
1583
+
1584
+ const finalHost = overrideHost ?? await this.resolveHostForRecipient(params.recipient)
1585
+ const queryParams = new URLSearchParams({
1586
+ recipient: params.recipient,
1587
+ messageBox: params.messageBox
1588
+ })
1589
+
1590
+ Logger.log('[MB CLIENT] Getting messageBox quote...')
1591
+
1592
+ const response = await this.authFetch.fetch(`${finalHost}/permissions/quote?${queryParams.toString()}`, {
1593
+ method: 'GET'
1594
+ })
1595
+
1596
+ if (!response.ok) {
1597
+ const errorData = await response.json().catch(() => ({}))
1598
+ throw new Error(`Failed to get quote: HTTP ${response.status} - ${String(errorData.description) ?? response.statusText}`)
1599
+ }
1600
+
1601
+ const { status, description, quote } = await response.json()
1602
+ if (status === 'error') {
1603
+ throw new Error(description ?? 'Failed to get quote')
1604
+ }
1605
+
1606
+ const deliveryAgentIdentityKey = response.headers.get('x-bsv-auth-identity-key')
1607
+
1608
+ if (deliveryAgentIdentityKey == null) {
1609
+ throw new Error('Failed to get quote: Delivery agent did not provide their identity key')
1610
+ }
1611
+
1612
+ return {
1613
+ recipientFee: quote.recipientFee,
1614
+ deliveryFee: quote.deliveryFee,
1615
+ deliveryAgentIdentityKey
1616
+ }
1617
+ }
1618
+
1619
+ /**
1620
+ * @method listMessageBoxPermissions
1621
+ * @async
1622
+ * @param {ListPermissionsParams} [params] - Optional filtering and pagination parameters
1623
+ * @returns {Promise<MessageBoxPermission[]>} List of current permissions
1624
+ *
1625
+ * @description
1626
+ * Lists permissions for the authenticated user's messageBoxes with optional pagination.
1627
+ *
1628
+ * @example
1629
+ * // List all permissions
1630
+ * const all = await client.listMessageBoxPermissions()
1631
+ *
1632
+ * // List only notification permissions with pagination
1633
+ * const notifications = await client.listMessageBoxPermissions({
1634
+ * messageBox: 'notifications',
1635
+ * limit: 50,
1636
+ * offset: 0
1637
+ * })
1638
+ */
1639
+ async listMessageBoxPermissions(params?: ListPermissionsParams, overrideHost?: string): Promise<MessageBoxPermission[]> {
1640
+ await this.assertInitialized()
1641
+
1642
+ const finalHost = overrideHost ?? this.host
1643
+ const queryParams = new URLSearchParams()
1644
+
1645
+ if (params?.messageBox != null) {
1646
+ queryParams.set('message_box', params.messageBox)
1647
+ }
1648
+ if (params?.limit !== undefined) {
1649
+ queryParams.set('limit', params.limit.toString())
1650
+ }
1651
+ if (params?.offset !== undefined) {
1652
+ queryParams.set('offset', params.offset.toString())
1653
+ }
1654
+
1655
+ Logger.log('[MB CLIENT] Listing messageBox permissions with params:', queryParams.toString())
1656
+
1657
+ const response = await this.authFetch.fetch(`${finalHost}/permissions/list?${queryParams.toString()}`, {
1658
+ method: 'GET'
1659
+ })
1660
+
1661
+ if (!response.ok) {
1662
+ const errorData = await response.json().catch(() => ({}))
1663
+ throw new Error(`Failed to list permissions: HTTP ${response.status} - ${String(errorData.description) !== '' ? String(errorData.description) : response.statusText}`)
1664
+ }
1665
+
1666
+ const data = await response.json()
1667
+ if (data.status === 'error') {
1668
+ throw new Error(data.description ?? 'Failed to list permissions')
1669
+ }
1670
+
1671
+ return data.permissions.map((p: any) => ({
1672
+ sender: p.sender,
1673
+ messageBox: p.message_box,
1674
+ recipientFee: p.recipient_fee,
1675
+ status: MessageBoxClient.getStatusFromFee(p.recipient_fee),
1676
+ createdAt: p.created_at,
1677
+ updatedAt: p.updated_at
1678
+ }))
1679
+ }
1680
+
1681
+ // ===========================
1682
+ // NOTIFICATION CONVENIENCE METHODS
1683
+ // ===========================
1684
+
1685
+ /**
1686
+ * @method allowNotificationsFromPeer
1687
+ * @async
1688
+ * @param {PubKeyHex} identityKey - Sender's identity key to allow
1689
+ * @param {number} [recipientFee=0] - Fee to charge (0 for always allow)
1690
+ * @param {string} [overrideHost] - Optional host override
1691
+ * @returns {Promise<void>} Permission status after allowing
1692
+ *
1693
+ * @description
1694
+ * Convenience method to allow notifications from a specific peer.
1695
+ *
1696
+ * @example
1697
+ * await client.allowNotificationsFromPeer('03abc123...') // Always allow
1698
+ * await client.allowNotificationsFromPeer('03def456...', 5) // Allow for 5 sats
1699
+ */
1700
+ async allowNotificationsFromPeer(identityKey: PubKeyHex, recipientFee: number = 0, overrideHost?: string): Promise<void> {
1701
+ await this.setMessageBoxPermission({
1702
+ messageBox: 'notifications',
1703
+ sender: identityKey,
1704
+ recipientFee
1705
+ }, overrideHost)
1706
+ }
1707
+
1708
+ /**
1709
+ * @method denyNotificationsFromPeer
1710
+ * @async
1711
+ * @param {PubKeyHex} identityKey - Sender's identity key to block
1712
+ * @returns {Promise<void>} Permission status after denying
1713
+ *
1714
+ * @description
1715
+ * Convenience method to block notifications from a specific peer.
1716
+ *
1717
+ * @example
1718
+ * await client.denyNotificationsFromPeer('03spam123...')
1719
+ */
1720
+ async denyNotificationsFromPeer(identityKey: PubKeyHex, overrideHost?: string): Promise<void> {
1721
+ await this.setMessageBoxPermission({
1722
+ messageBox: 'notifications',
1723
+ sender: identityKey,
1724
+ recipientFee: -1
1725
+ }, overrideHost)
1726
+ }
1727
+
1728
+ /**
1729
+ * @method checkPeerNotificationStatus
1730
+ * @async
1731
+ * @param {PubKeyHex} identityKey - Sender's identity key to check
1732
+ * @returns {Promise<MessageBoxPermission>} Current permission status
1733
+ *
1734
+ * @description
1735
+ * Convenience method to check notification permission for a specific peer.
1736
+ *
1737
+ * @example
1738
+ * const status = await client.checkPeerNotificationStatus('03abc123...')
1739
+ * console.log(status.allowed) // true/false
1740
+ */
1741
+ async checkPeerNotificationStatus(identityKey: PubKeyHex, overrideHost?: string): Promise<MessageBoxPermission | null> {
1742
+ const myIdentityKey = await this.getIdentityKey()
1743
+ return await this.getMessageBoxPermission({
1744
+ recipient: myIdentityKey,
1745
+ messageBox: 'notifications',
1746
+ sender: identityKey
1747
+ }, overrideHost)
1748
+ }
1749
+
1750
+ /**
1751
+ * @method listPeerNotifications
1752
+ * @async
1753
+ * @returns {Promise<MessageBoxPermission[]>} List of notification permissions
1754
+ *
1755
+ * @description
1756
+ * Convenience method to list all notification permissions.
1757
+ *
1758
+ * @example
1759
+ * const notifications = await client.listPeerNotifications()
1760
+ */
1761
+ async listPeerNotifications(overrideHost?: string): Promise<MessageBoxPermission[]> {
1762
+ return await this.listMessageBoxPermissions({ messageBox: 'notifications' }, overrideHost)
1763
+ }
1764
+
1765
+ /**
1766
+ * @method sendNotification
1767
+ * @async
1768
+ * @param {PubKeyHex} recipient - Recipient's identity key
1769
+ * @param {string | object} body - Notification content
1770
+ * @param {string} [overrideHost] - Optional host override
1771
+ * @returns {Promise<SendMessageResponse>} Send result
1772
+ *
1773
+ * @description
1774
+ * Convenience method to send a notification with automatic quote fetching and payment handling.
1775
+ * Automatically determines the required payment amount and creates the payment if needed.
1776
+ *
1777
+ * @example
1778
+ * // Send notification (auto-determines payment needed)
1779
+ * await client.sendNotification('03def456...', 'Hello!')
1780
+ *
1781
+ * // Send with maximum payment limit for safety
1782
+ * await client.sendNotification('03def456...', { title: 'Alert', body: 'Important update' }, 50)
1783
+ */
1784
+ async sendNotification(
1785
+ recipient: PubKeyHex,
1786
+ body: string | object,
1787
+ overrideHost?: string
1788
+ ): Promise<SendMessageResponse> {
1789
+ await this.assertInitialized()
1790
+
1791
+ // Use sendMessage with permission checking enabled
1792
+ // This eliminates duplication of quote fetching and payment logic
1793
+ return await this.sendMessage({
1794
+ recipient,
1795
+ messageBox: 'notifications',
1796
+ body,
1797
+ checkPermissions: true
1798
+ }, overrideHost)
1799
+ }
1800
+
1801
+ /**
1802
+ * Register a device for FCM push notifications.
1803
+ *
1804
+ * @async
1805
+ * @param {DeviceRegistrationParams} params - Device registration parameters
1806
+ * @param {string} [overrideHost] - Optional host override
1807
+ * @returns {Promise<DeviceRegistrationResponse>} Registration response
1808
+ *
1809
+ * @description
1810
+ * Registers a device with the message box server to receive FCM push notifications.
1811
+ * The FCM token is obtained from Firebase SDK on the client side.
1812
+ *
1813
+ * @example
1814
+ * const result = await client.registerDevice({
1815
+ * fcmToken: 'eBo8F...',
1816
+ * platform: 'ios',
1817
+ * deviceId: 'iPhone15Pro'
1818
+ * })
1819
+ */
1820
+ async registerDevice(
1821
+ params: DeviceRegistrationParams,
1822
+ overrideHost?: string
1823
+ ): Promise<DeviceRegistrationResponse> {
1824
+ await this.assertInitialized()
1825
+
1826
+ if (params.fcmToken == null || params.fcmToken.trim() === '') {
1827
+ throw new Error('fcmToken is required and must be a non-empty string')
1828
+ }
1829
+
1830
+ // Validate platform if provided
1831
+ const validPlatforms = ['ios', 'android', 'web']
1832
+ if (params.platform != null && !validPlatforms.includes(params.platform)) {
1833
+ throw new Error('platform must be one of: ios, android, web')
1834
+ }
1835
+
1836
+ const finalHost = overrideHost ?? this.host
1837
+
1838
+ Logger.log('[MB CLIENT] Registering device for FCM notifications...')
1839
+
1840
+ const response = await this.authFetch.fetch(`${finalHost}/registerDevice`, {
1841
+ method: 'POST',
1842
+ headers: { 'Content-Type': 'application/json' },
1843
+ body: JSON.stringify({
1844
+ fcmToken: params.fcmToken.trim(),
1845
+ deviceId: params.deviceId?.trim() ?? undefined,
1846
+ platform: params.platform ?? undefined
1847
+ })
1848
+ })
1849
+
1850
+ if (!response.ok) {
1851
+ const errorData = await response.json().catch(() => ({}))
1852
+ const description = String(errorData.description) ?? response.statusText
1853
+ throw new Error(`Failed to register device: HTTP ${response.status} - ${description}`)
1854
+ }
1855
+
1856
+ const data = await response.json()
1857
+ if (data.status === 'error') {
1858
+ throw new Error(data.description ?? 'Failed to register device')
1859
+ }
1860
+
1861
+ Logger.log('[MB CLIENT] Device registered successfully')
1862
+ return {
1863
+ status: data.status,
1864
+ message: data.message,
1865
+ deviceId: data.deviceId
1866
+ }
1867
+ }
1868
+
1869
+ /**
1870
+ * List all registered devices for push notifications.
1871
+ *
1872
+ * @async
1873
+ * @param {string} [overrideHost] - Optional host override
1874
+ * @returns {Promise<RegisteredDevice[]>} Array of registered devices
1875
+ *
1876
+ * @description
1877
+ * Retrieves all devices registered by the authenticated user for FCM push notifications.
1878
+ * Only shows devices belonging to the current user (authenticated via AuthFetch).
1879
+ *
1880
+ * @example
1881
+ * const devices = await client.listRegisteredDevices()
1882
+ * console.log(`Found ${devices.length} registered devices`)
1883
+ * devices.forEach(device => {
1884
+ * console.log(`Device: ${device.platform} - ${device.fcmToken}`)
1885
+ * })
1886
+ */
1887
+ async listRegisteredDevices(
1888
+ overrideHost?: string
1889
+ ): Promise<RegisteredDevice[]> {
1890
+ await this.assertInitialized()
1891
+
1892
+ const finalHost = overrideHost ?? this.host
1893
+
1894
+ Logger.log('[MB CLIENT] Listing registered devices...')
1895
+
1896
+ const response = await this.authFetch.fetch(`${finalHost}/devices`, {
1897
+ method: 'GET'
1898
+ })
1899
+
1900
+ if (!response.ok) {
1901
+ const errorData = await response.json().catch(() => ({}))
1902
+ const description = String(errorData.description) ?? response.statusText
1903
+ throw new Error(`Failed to list devices: HTTP ${response.status} - ${description}`)
1904
+ }
1905
+
1906
+ const data: ListDevicesResponse = await response.json()
1907
+ if (data.status === 'error') {
1908
+ throw new Error(data.description ?? 'Failed to list devices')
1909
+ }
1910
+
1911
+ Logger.log(`[MB CLIENT] Found ${data.devices.length} registered devices`)
1912
+ return data.devices
1913
+ }
1914
+
1915
+ // ===========================
1916
+ // PRIVATE HELPER METHODS
1917
+ // ===========================
1918
+
1919
+ private static getStatusFromFee(fee: number): 'always_allow' | 'blocked' | 'payment_required' {
1920
+ if (fee === -1) return 'blocked'
1921
+ if (fee === 0) return 'always_allow'
1922
+ return 'payment_required'
1923
+ }
1924
+
1925
+ /**
1926
+ * Creates payment transaction for message delivery fees
1927
+ * TODO: Consider consolidating payment generating logic with a util PeerPayClient can use as well.
1928
+ * @private
1929
+ * @param {string} recipient - Recipient identity key
1930
+ * @param {MessageBoxQuote} quote - Fee quote with delivery and recipient fees
1931
+ * @param {string} description - Description for the payment transaction
1932
+ * @returns {Promise<Payment>} Payment transaction data
1933
+ */
1934
+ private async createMessagePayment(
1935
+ recipient: string,
1936
+ quote: MessageBoxQuote,
1937
+ description: string = 'MessageBox delivery payment'
1938
+ ): Promise<Payment> {
1939
+ if (quote.recipientFee <= 0 && quote.deliveryFee <= 0) {
1940
+ throw new Error('No payment required')
1941
+ }
1942
+
1943
+ Logger.log(`[MB CLIENT] Creating payment transaction for ${quote.recipientFee} sats (delivery: ${quote.deliveryFee}, recipient: ${quote.recipientFee})`)
1944
+
1945
+ const outputs: InternalizeOutput[] = []
1946
+ const createActionOutputs: CreateActionOutput[] = []
1947
+
1948
+ // Get sender identity key for remittance data
1949
+ const senderIdentityKey = await this.getIdentityKey()
1950
+
1951
+ // Add server delivery fee output if > 0
1952
+ let outputIndex = 0
1953
+ if (quote.deliveryFee > 0) {
1954
+ const derivationPrefix = Utils.toBase64(Random(32))
1955
+ const derivationSuffix = Utils.toBase64(Random(32))
1956
+
1957
+ // Get host's derived public key
1958
+ const { publicKey: derivedKeyResult } = await this.walletClient.getPublicKey({
1959
+ protocolID: [2, '3241645161d8'],
1960
+ keyID: `${derivationPrefix} ${derivationSuffix}`,
1961
+ counterparty: quote.deliveryAgentIdentityKey
1962
+ })
1963
+
1964
+ // Create locking script using host's public key
1965
+ const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex()
1966
+
1967
+ // Add to createAction outputs
1968
+ createActionOutputs.push({
1969
+ satoshis: quote.deliveryFee,
1970
+ lockingScript,
1971
+ outputDescription: 'MessageBox server delivery fee',
1972
+ customInstructions: JSON.stringify({
1973
+ derivationPrefix,
1974
+ derivationSuffix,
1975
+ recipientIdentityKey: quote.deliveryAgentIdentityKey
1976
+ })
1977
+ })
1978
+
1979
+ outputs.push({
1980
+ outputIndex: outputIndex++,
1981
+ protocol: 'wallet payment',
1982
+ paymentRemittance: {
1983
+ derivationPrefix,
1984
+ derivationSuffix,
1985
+ senderIdentityKey
1986
+ }
1987
+ })
1988
+ }
1989
+
1990
+ // Add recipient fee output if > 0
1991
+ if (quote.recipientFee > 0) {
1992
+ const derivationPrefix = Utils.toBase64(Random(32))
1993
+ const derivationSuffix = Utils.toBase64(Random(32))
1994
+ // Get a derived public key for the recipient that "anyone" can verify
1995
+ const anyoneWallet = new ProtoWallet('anyone')
1996
+ const { publicKey: derivedKeyResult } = await anyoneWallet.getPublicKey({
1997
+ protocolID: [2, '3241645161d8'],
1998
+ keyID: `${derivationPrefix} ${derivationSuffix}`,
1999
+ counterparty: recipient
2000
+ })
2001
+
2002
+ if (derivedKeyResult == null || derivedKeyResult.trim() === '') {
2003
+ throw new Error('Failed to derive recipient\'s public key')
2004
+ }
2005
+
2006
+ // Create locking script using recipient's public key
2007
+ const lockingScript = new P2PKH().lock(PublicKey.fromString(derivedKeyResult).toAddress()).toHex()
2008
+
2009
+ // Add to createAction outputs
2010
+ createActionOutputs.push({
2011
+ satoshis: quote.recipientFee,
2012
+ lockingScript,
2013
+ outputDescription: 'Recipient message fee',
2014
+ customInstructions: JSON.stringify({
2015
+ derivationPrefix,
2016
+ derivationSuffix,
2017
+ recipientIdentityKey: recipient
2018
+ })
2019
+ })
2020
+
2021
+ outputs.push({
2022
+ outputIndex: outputIndex++,
2023
+ protocol: 'wallet payment',
2024
+ paymentRemittance: {
2025
+ derivationPrefix,
2026
+ derivationSuffix,
2027
+ senderIdentityKey
2028
+ }
2029
+ })
2030
+ }
2031
+
2032
+ const { tx } = await this.walletClient.createAction({
2033
+ description,
2034
+ outputs: createActionOutputs,
2035
+ options: { randomizeOutputs: false, acceptDelayedBroadcast: false }
2036
+ })
2037
+
2038
+ if (tx == null) {
2039
+ throw new Error('Failed to create payment transaction')
2040
+ }
2041
+
2042
+ return {
2043
+ tx,
2044
+ outputs,
2045
+ description
2046
+ // labels
2047
+ }
2048
+ }
1341
2049
  }