@bopen-io/messagebox-server 1.1.5 → 1.2.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.
Files changed (113) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +11 -0
  3. package/.eslintignore +2 -0
  4. package/.gcloudignore +17 -0
  5. package/.gitattributes +2 -0
  6. package/.github/workflows/build-and-push.yml +51 -0
  7. package/.github/workflows/deploy.yaml.example +64 -0
  8. package/.github/workflows/image.yaml +66 -0
  9. package/.vscode/launch.json +67 -0
  10. package/AGENTS.md +92 -0
  11. package/BASELINE.md +55 -0
  12. package/DEPLOYING.md +120 -0
  13. package/Dockerfile +63 -0
  14. package/docker-compose.yml +64 -0
  15. package/jest.config.integration.ts +26 -0
  16. package/jest.config.mjs +42 -0
  17. package/jest.config.ts +35 -0
  18. package/jest.setup.mjs +18 -0
  19. package/nginx.conf +36 -0
  20. package/package.json +68 -52
  21. package/scripts/build.js +19 -0
  22. package/scripts/mkenv.sh +31 -0
  23. package/scripts/start.sh +16 -0
  24. package/src/__tests/compose.test.ts +30 -0
  25. package/src/app.ts +210 -0
  26. package/src/compose.ts +286 -0
  27. package/src/config/firebase.ts +183 -0
  28. package/src/context.ts +49 -0
  29. package/src/index.ts +375 -0
  30. package/src/routes/__tests/acknowledgeMessage.test.ts +158 -0
  31. package/src/routes/__tests/listMessages.test.ts +240 -0
  32. package/src/routes/__tests/sendMessage.test.ts +362 -0
  33. package/src/routes/acknowledgeMessage.ts +153 -0
  34. package/src/routes/index.ts +17 -0
  35. package/src/routes/listDevices.ts +80 -0
  36. package/src/routes/listMessages.ts +195 -0
  37. package/src/routes/permissions/getPermission.ts +152 -0
  38. package/src/routes/permissions/getQuote.ts +192 -0
  39. package/src/routes/permissions/index.ts +12 -0
  40. package/src/routes/permissions/listPermissions.ts +202 -0
  41. package/src/routes/permissions/setPermission.ts +155 -0
  42. package/src/routes/registerDevice.ts +155 -0
  43. package/src/routes/sendMessage.ts +531 -0
  44. package/src/runtimeDeps.ts +29 -0
  45. package/src/swagger.ts +54 -0
  46. package/src/telemetry.ts +158 -0
  47. package/src/types/messagePermissions.ts +18 -0
  48. package/src/types/mock-knex.d.ts +18 -0
  49. package/src/types/notifications.ts +27 -0
  50. package/src/utils/logger.ts +92 -0
  51. package/src/utils/messagePermissions.ts +148 -0
  52. package/src/utils/sendFCMNotification.ts +143 -0
  53. package/tsconfig.json +36 -0
  54. package/tsconfig.types.json +11 -0
  55. package/out/knexfile.js +0 -25
  56. package/out/knexfile.js.map +0 -1
  57. package/out/src/app.js +0 -160
  58. package/out/src/app.js.map +0 -1
  59. package/out/src/config/firebase.js +0 -150
  60. package/out/src/config/firebase.js.map +0 -1
  61. package/out/src/index.js +0 -295
  62. package/out/src/index.js.map +0 -1
  63. package/out/src/migrations/2022-12-28-001-initial-migration.js +0 -28
  64. package/out/src/migrations/2022-12-28-001-initial-migration.js.map +0 -1
  65. package/out/src/migrations/2023-01-17-messages-update.js +0 -11
  66. package/out/src/migrations/2023-01-17-messages-update.js.map +0 -1
  67. package/out/src/migrations/2024-03-05-001-messageID-upgrade.js +0 -21
  68. package/out/src/migrations/2024-03-05-001-messageID-upgrade.js.map +0 -1
  69. package/out/src/migrations/2025-01-31-001-notification-permissions.js +0 -59
  70. package/out/src/migrations/2025-01-31-001-notification-permissions.js.map +0 -1
  71. package/out/src/migrations/2025-01-31-002-device-registrations.js +0 -24
  72. package/out/src/migrations/2025-01-31-002-device-registrations.js.map +0 -1
  73. package/out/src/routes/__tests/acknowledgeMessage.test.js +0 -129
  74. package/out/src/routes/__tests/acknowledgeMessage.test.js.map +0 -1
  75. package/out/src/routes/__tests/listMessages.test.js +0 -211
  76. package/out/src/routes/__tests/listMessages.test.js.map +0 -1
  77. package/out/src/routes/__tests/sendMessage.test.js +0 -307
  78. package/out/src/routes/__tests/sendMessage.test.js.map +0 -1
  79. package/out/src/routes/acknowledgeMessage.js +0 -144
  80. package/out/src/routes/acknowledgeMessage.js.map +0 -1
  81. package/out/src/routes/index.js +0 -17
  82. package/out/src/routes/index.js.map +0 -1
  83. package/out/src/routes/listDevices.js +0 -74
  84. package/out/src/routes/listDevices.js.map +0 -1
  85. package/out/src/routes/listMessages.js +0 -186
  86. package/out/src/routes/listMessages.js.map +0 -1
  87. package/out/src/routes/permissions/getPermission.js +0 -149
  88. package/out/src/routes/permissions/getPermission.js.map +0 -1
  89. package/out/src/routes/permissions/getQuote.js +0 -165
  90. package/out/src/routes/permissions/getQuote.js.map +0 -1
  91. package/out/src/routes/permissions/index.js +0 -12
  92. package/out/src/routes/permissions/index.js.map +0 -1
  93. package/out/src/routes/permissions/listPermissions.js +0 -190
  94. package/out/src/routes/permissions/listPermissions.js.map +0 -1
  95. package/out/src/routes/permissions/setPermission.js +0 -137
  96. package/out/src/routes/permissions/setPermission.js.map +0 -1
  97. package/out/src/routes/registerDevice.js +0 -150
  98. package/out/src/routes/registerDevice.js.map +0 -1
  99. package/out/src/routes/sendMessage.js +0 -451
  100. package/out/src/routes/sendMessage.js.map +0 -1
  101. package/out/src/swagger.js +0 -49
  102. package/out/src/swagger.js.map +0 -1
  103. package/out/src/types/messagePermissions.js +0 -2
  104. package/out/src/types/messagePermissions.js.map +0 -1
  105. package/out/src/types/notifications.js +0 -2
  106. package/out/src/types/notifications.js.map +0 -1
  107. package/out/src/utils/logger.js +0 -24
  108. package/out/src/utils/logger.js.map +0 -1
  109. package/out/src/utils/messagePermissions.js +0 -126
  110. package/out/src/utils/messagePermissions.js.map +0 -1
  111. package/out/src/utils/sendFCMNotification.js +0 -121
  112. package/out/src/utils/sendFCMNotification.js.map +0 -1
  113. package/out/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,155 @@
1
+ import { Response } from 'express'
2
+ import { Logger } from '../utils/logger.js'
3
+ import { AuthRequest } from '@bsv/auth-express-middleware'
4
+ import { runtimeDeps } from '../runtimeDeps.js'
5
+
6
+ export interface RegisterDeviceRequest extends AuthRequest {
7
+ body: {
8
+ fcmToken: string
9
+ deviceId?: string
10
+ platform?: string // 'ios' | 'android' | 'web'
11
+ }
12
+ }
13
+
14
+ /**
15
+ * @swagger
16
+ * /registerDevice:
17
+ * post:
18
+ * summary: Register device for push notifications
19
+ * description: Register a device's FCM token for receiving push notifications
20
+ * tags:
21
+ * - Device
22
+ * requestBody:
23
+ * required: true
24
+ * content:
25
+ * application/json:
26
+ * schema:
27
+ * type: object
28
+ * required:
29
+ * - fcmToken
30
+ * properties:
31
+ * fcmToken:
32
+ * type: string
33
+ * description: Firebase Cloud Messaging token
34
+ * deviceId:
35
+ * type: string
36
+ * description: Optional device identifier
37
+ * platform:
38
+ * type: string
39
+ * description: Device platform (ios, android, web)
40
+ * enum: [ios, android, web]
41
+ * responses:
42
+ * 200:
43
+ * description: Device registered successfully
44
+ * content:
45
+ * application/json:
46
+ * schema:
47
+ * type: object
48
+ * properties:
49
+ * status:
50
+ * type: string
51
+ * example: success
52
+ * message:
53
+ * type: string
54
+ * example: Device registered successfully
55
+ * deviceId:
56
+ * type: integer
57
+ * description: Database ID of the registered device
58
+ * 400:
59
+ * description: Invalid request parameters
60
+ * 401:
61
+ * description: Authentication required
62
+ * 500:
63
+ * description: Internal server error
64
+ */
65
+
66
+ export default {
67
+ type: 'post',
68
+ path: '/registerDevice',
69
+ func: async (req: RegisterDeviceRequest, res: Response): Promise<Response> => {
70
+ try {
71
+ Logger.log('[DEBUG] Processing device registration request')
72
+
73
+ // Validate authentication
74
+ const identityKey = req.auth?.identityKey
75
+ if (identityKey == null) {
76
+ Logger.log('[DEBUG] Authentication required for device registration')
77
+ return res.status(401).json({
78
+ status: 'error',
79
+ code: 'ERR_AUTHENTICATION_REQUIRED',
80
+ description: 'Authentication required.'
81
+ })
82
+ }
83
+
84
+ const { fcmToken, deviceId, platform } = req.body
85
+
86
+ // Validate required fields
87
+ if (fcmToken == null || typeof fcmToken !== 'string' || fcmToken.trim() === '') {
88
+ Logger.log('[DEBUG] Invalid FCM token provided')
89
+ return res.status(400).json({
90
+ status: 'error',
91
+ code: 'ERR_INVALID_FCM_TOKEN',
92
+ description: 'fcmToken is required and must be a non-empty string.'
93
+ })
94
+ }
95
+
96
+ // Validate platform if provided
97
+ const validPlatforms = ['ios', 'android', 'web']
98
+ if (platform != null && !validPlatforms.includes(platform)) {
99
+ Logger.log('[DEBUG] Invalid platform provided')
100
+ return res.status(400).json({
101
+ status: 'error',
102
+ code: 'ERR_INVALID_PLATFORM',
103
+ description: 'platform must be one of: ios, android, web'
104
+ })
105
+ }
106
+
107
+ try {
108
+ // Insert or update device registration
109
+ const now = new Date()
110
+ const [deviceRegistrationId] = await runtimeDeps.knex('device_registrations')
111
+ .insert({
112
+ identity_key: identityKey,
113
+ fcm_token: fcmToken.trim(),
114
+ device_id: deviceId?.trim() ?? null,
115
+ platform: platform ?? null,
116
+ created_at: now,
117
+ updated_at: now,
118
+ active: true,
119
+ last_used: now
120
+ })
121
+ .onConflict('fcm_token')
122
+ .merge({
123
+ identity_key: identityKey, // Update identity key in case token was reassigned
124
+ device_id: deviceId?.trim() ?? null,
125
+ platform: platform ?? null,
126
+ updated_at: now,
127
+ active: true,
128
+ last_used: now
129
+ })
130
+
131
+ Logger.log(`[DEBUG] Device registered successfully: ${identityKey} with token ending in ...${fcmToken.slice(-10)}`)
132
+
133
+ return res.status(200).json({
134
+ status: 'success',
135
+ message: 'Device registered successfully for push notifications',
136
+ deviceId: deviceRegistrationId
137
+ })
138
+ } catch (dbError: any) {
139
+ Logger.error('[ERROR] Database error during device registration:', dbError)
140
+ return res.status(500).json({
141
+ status: 'error',
142
+ code: 'ERR_DATABASE_ERROR',
143
+ description: 'Failed to register device.'
144
+ })
145
+ }
146
+ } catch (error) {
147
+ Logger.error('[ERROR] Internal Server Error in registerDevice:', error)
148
+ return res.status(500).json({
149
+ status: 'error',
150
+ code: 'ERR_INTERNAL',
151
+ description: 'An internal error has occurred.'
152
+ })
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,531 @@
1
+ /**
2
+ * @file sendMessage.ts
3
+ * @description
4
+ * Route handler to send a message to another identity's messageBox.
5
+ * This route is used for P2P communication in the MessageBox system.
6
+ *
7
+ * It handles:
8
+ * - Validation of message structure
9
+ * - Validation of the recipient public key
10
+ * - MessageBox creation if one doesn't exist
11
+ * - Insertion of the message into the database
12
+ * - Deduplication based on messageId
13
+ *
14
+ */
15
+
16
+ import { Response } from 'express'
17
+ import {
18
+ AtomicBEEF,
19
+ Base64String,
20
+ BasketStringUnder300Bytes,
21
+ BooleanDefaultTrue,
22
+ DescriptionString5to50Bytes,
23
+ LabelStringUnder300Bytes,
24
+ OutputTagStringUnder300Bytes,
25
+ PositiveIntegerOrZero,
26
+ PubKeyHex,
27
+ PublicKey,
28
+ } from '@bsv/sdk'
29
+ import { Logger, log } from '../utils/logger.js'
30
+ import { AuthRequest } from '@bsv/auth-express-middleware'
31
+ import { sendFCMNotification } from '../utils/sendFCMNotification.js'
32
+ import { getRecipientFee, getServerDeliveryFee, shouldUseFCMDelivery } from '../utils/messagePermissions.js'
33
+ import { runtimeDeps, getWallet } from '../runtimeDeps.js'
34
+
35
+ const { SERVER_PRIVATE_KEY } = process.env
36
+
37
+ // Type definition for the incoming message format
38
+ export interface Message {
39
+ // Back-compat: accept 'recipient' (string or array) AND new 'recipients' (array)
40
+ recipient: PubKeyHex | PubKeyHex[]
41
+ recipients?: PubKeyHex[]
42
+ messageBox: string
43
+ messageId: string | string[] // one per recipient, same order as recipients
44
+ body: string
45
+ }
46
+
47
+ export interface Payment {
48
+ tx: AtomicBEEF
49
+ outputs: Array<{
50
+ outputIndex: PositiveIntegerOrZero
51
+ protocol: 'wallet payment' | 'basket insertion'
52
+ paymentRemittance?: {
53
+ derivationPrefix: Base64String
54
+ derivationSuffix: Base64String
55
+ senderIdentityKey: PubKeyHex
56
+ // NOTE: We intentionally do NOT type this strictly;
57
+ // some clients may include a JSON string here.
58
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
59
+ // @ts-ignore - custom extension
60
+ customInstructions?: unknown
61
+ }
62
+ insertionRemittance?: {
63
+ basket: BasketStringUnder300Bytes
64
+ customInstructions?: string
65
+ tags?: OutputTagStringUnder300Bytes[]
66
+ }
67
+ }>
68
+ description: DescriptionString5to50Bytes
69
+ labels?: LabelStringUnder300Bytes[]
70
+ seekPermission?: BooleanDefaultTrue
71
+ }
72
+
73
+ export interface SendMessageRequest extends AuthRequest {
74
+ body: {
75
+ message?: Message
76
+ payment?: Payment
77
+ }
78
+ }
79
+
80
+ // Validate critical server-side secret
81
+ if (SERVER_PRIVATE_KEY == null || SERVER_PRIVATE_KEY.trim() === '') {
82
+ throw new Error('SERVER_PRIVATE_KEY is not defined in the environment variables.')
83
+ }
84
+
85
+ /**
86
+ * @function calculateMessagePrice
87
+ * @description Determines the price (in satoshis) to send a message, optionally with priority.
88
+ */
89
+ export function calculateMessagePrice(message: string, priority: boolean = false): number {
90
+ const basePrice = 2 // Base fee in satoshis
91
+ const sizeFactor = Math.ceil(Buffer.byteLength(message, 'utf8') / 1024) * 3 // Satoshis per KB
92
+ return basePrice + sizeFactor
93
+ }
94
+
95
+ /**
96
+ * @openapi
97
+ * /sendMessage:
98
+ * post:
99
+ * summary: Send a message to a recipient’s message box
100
+ * description: |
101
+ * Inserts a message into the target recipient’s message box on the server.
102
+ * The recipient, message box name, and message ID must be provided.
103
+ * tags:
104
+ * - Message
105
+ * requestBody:
106
+ * required: true
107
+ * content:
108
+ * application/json:
109
+ * schema:
110
+ * type: object
111
+ * properties:
112
+ * message:
113
+ * type: object
114
+ * required:
115
+ * - recipient
116
+ * - messageBox
117
+ * - messageId
118
+ * - body
119
+ * properties:
120
+ * recipient:
121
+ * type: string
122
+ * description: Identity key of the recipient
123
+ * messageBox:
124
+ * type: string
125
+ * description: The name of the recipient's message box
126
+ * messageId:
127
+ * type: string
128
+ * description: Unique identifier for the message (usually an HMAC)
129
+ * body:
130
+ * oneOf:
131
+ * - type: string
132
+ * - type: object
133
+ * description: The message content
134
+ * responses:
135
+ * 200:
136
+ * description: Message stored successfully
137
+ * content:
138
+ * application/json:
139
+ * schema:
140
+ * type: object
141
+ * properties:
142
+ * status:
143
+ * type: string
144
+ * example: success
145
+ * messageId:
146
+ * type: string
147
+ * message:
148
+ * type: string
149
+ * 400:
150
+ * description: Invalid request or duplicate message
151
+ * 500:
152
+ * description: Internal server error
153
+ */
154
+
155
+ /**
156
+ * @exports
157
+ * Express-compatible route definition for `/sendMessage`, used to send messages to other users.
158
+ * Contains metadata for auto-generation of route documentation and Swagger/OpenAPI integration.
159
+ */
160
+ export default {
161
+ type: 'post',
162
+ path: '/sendMessage',
163
+ get knex () { return runtimeDeps.knex },
164
+ summary: "Use this route to send a message to a recipient's message box.",
165
+ parameters: {
166
+ message: {
167
+ recipient: '028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1',
168
+ messageBox: 'payment_inbox',
169
+ messageId: 'xyz123',
170
+ body: '{}'
171
+ }
172
+ },
173
+ exampleResponse: { status: 'success' },
174
+
175
+ func: async (req: SendMessageRequest, res: Response): Promise<Response> => {
176
+ Logger.log('[DEBUG] Processing /sendMessage request...')
177
+ Logger.log('[DEBUG] Request Headers:', JSON.stringify(req.headers, null, 2))
178
+
179
+ const senderKey = req.auth?.identityKey
180
+ if (senderKey == null) {
181
+ return res.status(401).json({
182
+ status: 'error',
183
+ code: 'ERR_AUTH_REQUIRED',
184
+ description: 'Authentication required'
185
+ })
186
+ }
187
+
188
+ try {
189
+ const { message, payment } = req.body
190
+ log.info({ operation: 'message.send', message_box: message?.messageBox, has_payment: payment != null }, 'Received message send request')
191
+
192
+ if (message == null) {
193
+ Logger.error('[ERROR] No message provided in request body!')
194
+ return res.status(400).json({
195
+ status: 'error',
196
+ code: 'ERR_MESSAGE_REQUIRED',
197
+ description: 'Please provide a valid message to send!'
198
+ })
199
+ }
200
+
201
+ if (typeof message.messageBox !== 'string' || message.messageBox.trim() === '') {
202
+ return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGEBOX', description: 'Invalid message box.' })
203
+ }
204
+
205
+ if (
206
+ (typeof message.body !== 'string' && (typeof message.body !== 'object' || message.body === null)) ||
207
+ (typeof message.body === 'string' && message.body.trim() === '')
208
+ ) {
209
+ return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGE_BODY', description: 'Invalid message body.' })
210
+ }
211
+
212
+ // ---------- Back-compat normalization ----------
213
+ // Accept message.recipients (array) or message.recipient (string|array)
214
+ const recipientsRaw = (message as any).recipients ?? (message as any).recipient
215
+ if (recipientsRaw == null) {
216
+ return res.status(400).json({
217
+ status: 'error',
218
+ code: 'ERR_RECIPIENT_REQUIRED',
219
+ description: 'Missing recipient(s). Provide "recipient" or "recipients".'
220
+ })
221
+ }
222
+ const recipients: string[] = Array.isArray(recipientsRaw)
223
+ ? recipientsRaw
224
+ : [recipientsRaw]
225
+
226
+ const messageIdRaw = message.messageId
227
+ if (messageIdRaw == null) {
228
+ return res.status(400).json({
229
+ status: 'error',
230
+ code: 'ERR_MESSAGEID_REQUIRED',
231
+ description: 'Missing messageId.'
232
+ })
233
+ }
234
+ const messageIds: string[] = Array.isArray(messageIdRaw)
235
+ ? messageIdRaw
236
+ : [messageIdRaw]
237
+
238
+ // If multiple recipients but only one messageId provided, fail clearly (avoid accidental reuse)
239
+ if (recipients.length > 1 && messageIds.length === 1) {
240
+ return res.status(400).json({
241
+ status: 'error',
242
+ code: 'ERR_MESSAGEID_COUNT_MISMATCH',
243
+ description: `Provided 1 messageId for ${recipients.length} recipients. Provide one messageId per recipient (same order).`
244
+ })
245
+ }
246
+ if (messageIds.length !== recipients.length) {
247
+ return res.status(400).json({
248
+ status: 'error',
249
+ code: 'ERR_MESSAGEID_COUNT_MISMATCH',
250
+ description: `Recipients (${recipients.length}) and messageId count (${messageIds.length}) must match.`
251
+ })
252
+ }
253
+
254
+ // Validate each messageId
255
+ for (const id of messageIds) {
256
+ if (typeof id !== 'string' || id.trim() === '') {
257
+ return res.status(400).json({ status: 'error', code: 'ERR_INVALID_MESSAGEID', description: 'Each messageId must be a non-empty string.' })
258
+ }
259
+ }
260
+
261
+ // Validate recipient keys & build map(recipient -> messageId)
262
+ const recipientsTrimmed = recipients.map(r => String(r).trim())
263
+ const msgIdByRecipient = new Map<string, string>()
264
+
265
+ for (let i = 0; i < recipientsTrimmed.length; i++) {
266
+ const r = recipientsTrimmed[i]
267
+ try {
268
+ PublicKey.fromString(r)
269
+ } catch {
270
+ return res.status(400).json({
271
+ status: 'error',
272
+ code: 'ERR_INVALID_RECIPIENT_KEY',
273
+ description: `Invalid recipient key: ${r}`
274
+ })
275
+ }
276
+ msgIdByRecipient.set(r, messageIds[i])
277
+ }
278
+
279
+ // Ensure messageBox exists for each recipient
280
+ const boxType = message.messageBox.trim()
281
+ for (const r of recipientsTrimmed) {
282
+ const existing = await runtimeDeps.knex('messageBox').where({ identityKey: r, type: boxType }).first()
283
+ if (!existing) {
284
+ await runtimeDeps.knex('messageBox').insert({
285
+ identityKey: r, type: boxType, created_at: new Date(), updated_at: new Date()
286
+ })
287
+ }
288
+ }
289
+
290
+ // ---------- Fee evaluation ----------
291
+ const deliveryFeeOnce = await getServerDeliveryFee(boxType)
292
+
293
+ type FeeRow = { recipient: string; recipientFee: number; allowed: boolean; blockedReason?: string }
294
+ const feeRows: FeeRow[] = []
295
+ for (const r of recipientsTrimmed) {
296
+ const rf = await getRecipientFee(r, senderKey, boxType) // -1 = blocked; 0 = allow; >0 = sats required
297
+ if (rf === -1) feeRows.push({ recipient: r, recipientFee: rf, allowed: false, blockedReason: `Messages to ${r} are blocked` })
298
+ else feeRows.push({ recipient: r, recipientFee: rf, allowed: true })
299
+ }
300
+
301
+ // Blocked recipients short-circuit
302
+ const blocked = feeRows.filter(f => !f.allowed).map(f => f.recipient)
303
+ if (blocked.length) {
304
+ return res.status(403).json({
305
+ status: 'error',
306
+ code: 'ERR_DELIVERY_BLOCKED',
307
+ description: `Blocked recipients: ${blocked.join(', ')}`,
308
+ blockedRecipients: blocked
309
+ })
310
+ }
311
+
312
+ const anyRecipientFee = feeRows.some(f => f.recipientFee > 0)
313
+ const requiresPayment = (deliveryFeeOnce > 0) || anyRecipientFee
314
+
315
+ // ---------- Payment internalization (batch) ----------
316
+ const perRecipientOutputs = new Map<string, any[]>()
317
+
318
+ if (requiresPayment) {
319
+ if (!payment?.tx || !Array.isArray(payment.outputs)) {
320
+ return res.status(400).json({
321
+ status: 'error',
322
+ code: 'ERR_MISSING_PAYMENT_TX',
323
+ description: 'Payment transaction data is required for payable delivery.'
324
+ })
325
+ }
326
+
327
+ // Enforce: index 0 is server delivery output (when needed)
328
+ if (deliveryFeeOnce > 0) {
329
+ if (payment.outputs.length === 0) {
330
+ return res.status(400).json({
331
+ status: 'error',
332
+ code: 'ERR_MISSING_DELIVERY_OUTPUT',
333
+ description: 'Delivery fee required but no outputs were provided.'
334
+ })
335
+ }
336
+ const serverDeliveryOutput = payment.outputs[0]
337
+ try {
338
+ const wallet = await getWallet()
339
+ const internalizeResult = await wallet.internalizeAction({
340
+ tx: payment.tx,
341
+ outputs: [serverDeliveryOutput],
342
+ description: payment.description ?? 'MessageBox delivery payment (batch)'
343
+ })
344
+ if (!internalizeResult.accepted) {
345
+ return res.status(400).json({
346
+ status: 'error',
347
+ code: 'ERR_INSUFFICIENT_PAYMENT',
348
+ description: 'Payment was not accepted by the server.'
349
+ })
350
+ }
351
+ Logger.log('[DEBUG] Internalized server delivery output at index 0')
352
+ } catch (error) {
353
+ Logger.error('[ERROR] Failed to internalize delivery fee payment:', error)
354
+ return res.status(500).json({
355
+ status: 'error',
356
+ code: 'ERR_INTERNALIZE_FAILED',
357
+ description: `Failed to internalize payment: ${error instanceof Error ? error.message : 'Unknown error'}`
358
+ })
359
+ }
360
+ }
361
+
362
+ // ---------- Build per-recipient outputs ----------
363
+ const recipientSideOutputs = payment.outputs.slice(deliveryFeeOnce > 0 ? 1 : 0)
364
+ log.info({ operation: 'message.send', recipient_output_count: recipientSideOutputs.length, total_output_count: payment.outputs.length }, 'Payment outputs')
365
+
366
+ const feeRecipients = feeRows.filter(f => f.recipientFee > 0).map(f => f.recipient)
367
+
368
+ // Try explicit mapping via customInstructions (in insertionRemittance OR paymentRemittance)
369
+ const outputsByRecipientKey = new Map<string, any[]>()
370
+ const usedIndexes = new Set<number>()
371
+
372
+ for (const out of recipientSideOutputs) {
373
+ const raw =
374
+ (out as any)?.insertionRemittance?.customInstructions ??
375
+ (out as any)?.paymentRemittance?.customInstructions ??
376
+ (out as any)?.customInstructions
377
+
378
+ if (!raw) continue
379
+
380
+ try {
381
+ const instr = typeof raw === 'string' ? JSON.parse(raw) : raw
382
+ const key = instr?.recipientIdentityKey
383
+ if (typeof key === 'string' && key.trim() !== '') {
384
+ if (!outputsByRecipientKey.has(key)) outputsByRecipientKey.set(key, [])
385
+ outputsByRecipientKey.get(key)!.push(out)
386
+ if (typeof (out as any)?.outputIndex === 'number') {
387
+ usedIndexes.add((out as any).outputIndex)
388
+ }
389
+ }
390
+ } catch {
391
+ // ignore unparsable instructions
392
+ }
393
+ }
394
+
395
+ if (outputsByRecipientKey.size === 0) {
396
+ // No explicit tags: fallback to positional mapping for recipients that require a fee
397
+ if (recipientSideOutputs.length < feeRecipients.length) {
398
+ return res.status(400).json({
399
+ status: 'error',
400
+ code: 'ERR_INSUFFICIENT_OUTPUTS',
401
+ description: `Expected at least ${feeRecipients.length} recipient output(s) but received ${recipientSideOutputs.length}`
402
+ })
403
+ }
404
+
405
+ feeRecipients.forEach((r, idx) => {
406
+ const out = recipientSideOutputs[idx]
407
+ if (!perRecipientOutputs.has(r)) perRecipientOutputs.set(r, [])
408
+ perRecipientOutputs.get(r)!.push(out)
409
+ })
410
+ } else {
411
+ // Use tagged outputs where present
412
+ for (const r of feeRecipients) {
413
+ const tagged = outputsByRecipientKey.get(r) ?? []
414
+ if (tagged.length > 0) {
415
+ perRecipientOutputs.set(r, tagged)
416
+ }
417
+ }
418
+
419
+ // For any remaining fee recipients without tags, allocate unused outputs (positional)
420
+ const unmapped = feeRecipients.filter(r => !perRecipientOutputs.has(r))
421
+ if (unmapped.length > 0) {
422
+ const remaining = recipientSideOutputs.filter(o => {
423
+ const idx = (o as any)?.outputIndex
424
+ return typeof idx === 'number' ? !usedIndexes.has(idx) : true
425
+ })
426
+
427
+ if (remaining.length < unmapped.length) {
428
+ return res.status(400).json({
429
+ status: 'error',
430
+ code: 'ERR_INSUFFICIENT_OUTPUTS',
431
+ description: `Expected at least ${unmapped.length} additional recipient output(s) but only ${remaining.length} remain`
432
+ })
433
+ }
434
+
435
+ unmapped.forEach((r, i) => {
436
+ const out = remaining[i]
437
+ if (!perRecipientOutputs.has(r)) perRecipientOutputs.set(r, [])
438
+ perRecipientOutputs.get(r)!.push(out)
439
+ })
440
+ }
441
+
442
+ // Final safety check
443
+ for (const r of feeRecipients) {
444
+ if (!perRecipientOutputs.has(r) || perRecipientOutputs.get(r)!.length === 0) {
445
+ return res.status(400).json({
446
+ status: 'error',
447
+ code: 'ERR_MISSING_RECIPIENT_OUTPUTS',
448
+ description: `Recipient fee required but no outputs were provided for ${r}`
449
+ })
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ // ---------- Store messages (one per recipient) ----------
456
+ const results: Array<{ recipient: string; messageId: string }> = []
457
+ for (const { recipient: r } of feeRows) {
458
+ const mb = await runtimeDeps.knex('messageBox')
459
+ .where({ identityKey: r, type: boxType })
460
+ .select('messageBoxId')
461
+ .first()
462
+
463
+ const perRecipientMessageId = msgIdByRecipient.get(r)!
464
+ if (!perRecipientMessageId) {
465
+ return res.status(400).json({
466
+ status: 'error',
467
+ code: 'ERR_INVALID_MESSAGEID',
468
+ description: `Missing messageId for recipient ${r}`
469
+ })
470
+ }
471
+
472
+ const perRecipientPayment =
473
+ perRecipientOutputs.has(r) && req.body.payment
474
+ ? { ...req.body.payment, outputs: perRecipientOutputs.get(r)! }
475
+ : undefined
476
+
477
+ const storedBody = {
478
+ message: message.body,
479
+ ...(perRecipientPayment && { payment: perRecipientPayment })
480
+ }
481
+
482
+ try {
483
+ await runtimeDeps.knex('messages')
484
+ .insert({
485
+ messageId: perRecipientMessageId,
486
+ messageBoxId: mb?.messageBoxId ?? null,
487
+ sender: senderKey,
488
+ recipient: r,
489
+ body: JSON.stringify(storedBody),
490
+ created_at: new Date(),
491
+ updated_at: new Date()
492
+ })
493
+ .onConflict('messageId')
494
+ .ignore()
495
+
496
+ results.push({ recipient: r, messageId: perRecipientMessageId })
497
+ } catch (error: any) {
498
+ if (error?.code === 'ER_DUP_ENTRY') {
499
+ return res.status(400).json({
500
+ status: 'error',
501
+ code: 'ERR_DUPLICATE_MESSAGE',
502
+ description: 'Duplicate message.'
503
+ })
504
+ }
505
+ throw error
506
+ }
507
+
508
+ try {
509
+ if (shouldUseFCMDelivery(boxType)) {
510
+ await sendFCMNotification(r, { title: 'New Message', messageId: perRecipientMessageId })
511
+ }
512
+ } catch (deliveryError) {
513
+ Logger.error('[ERROR] Error processing FCM delivery:', deliveryError)
514
+ }
515
+ }
516
+
517
+ return res.status(200).json({
518
+ status: 'success',
519
+ message: `Your message has been sent to ${results.length} recipient(s).`,
520
+ results
521
+ })
522
+ } catch (error) {
523
+ Logger.error('[ERROR] Internal Server Error:', error)
524
+ return res.status(500).json({
525
+ status: 'error',
526
+ code: 'ERR_INTERNAL',
527
+ description: 'An internal error has occurred.'
528
+ })
529
+ }
530
+ }
531
+ }