@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
package/src/context.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Knex } from 'knex'
2
+ import type { WalletInterface } from '@bsv/sdk'
3
+ import type { Request } from 'express'
4
+
5
+ export interface MessageBoxContext {
6
+ wallet: WalletInterface
7
+ knex: Knex
8
+ routingPrefix: string
9
+ enableWebSockets: boolean
10
+ enableSwagger: boolean
11
+ calculateRequestPrice: (req: Request) => Promise<number> | number
12
+ logger: Console
13
+ }
14
+
15
+ export interface CreateMessageBoxContextOptions {
16
+ wallet: WalletInterface
17
+ knex: Knex
18
+ routingPrefix?: string
19
+ enableWebSockets?: boolean
20
+ enableSwagger?: boolean
21
+ calculateRequestPrice?: (req: Request) => Promise<number> | number
22
+ logger?: Console
23
+ }
24
+
25
+ export function createMessageBoxContext (
26
+ deps: CreateMessageBoxContextOptions
27
+ ): MessageBoxContext {
28
+ if (deps.wallet == null) {
29
+ throw new Error('createMessageBoxContext requires a wallet')
30
+ }
31
+ if (deps.knex == null) {
32
+ throw new Error('createMessageBoxContext requires a knex instance')
33
+ }
34
+
35
+ return {
36
+ wallet: deps.wallet,
37
+ knex: deps.knex,
38
+ routingPrefix: deps.routingPrefix ?? '',
39
+ enableWebSockets: deps.enableWebSockets ?? true,
40
+ enableSwagger: deps.enableSwagger ?? true,
41
+ calculateRequestPrice: deps.calculateRequestPrice ?? (async (req: Request) => {
42
+ if (req.url.includes('/sendMessage')) {
43
+ // configurable via deps.calculateRequestPrice
44
+ }
45
+ return 0
46
+ }),
47
+ logger: deps.logger ?? console
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * @file index.ts
3
+ * @description
4
+ * Main entry point for the MessageBox Server.
5
+ *
6
+ * Responsibilities:
7
+ * - Initializes environment variables and config
8
+ * - Creates HTTP and WebSocket servers
9
+ * - Boots authentication and route handlers
10
+ * - Sets up database migrations after a short delay
11
+ * - Emits and handles real-time message events over WebSocket
12
+ *
13
+ * Exports:
14
+ * - `start()` for programmatic bootstrapping
15
+ * - `http` and `io` server instances
16
+ * - `HTTP_PORT` and `ROUTING_PREFIX` for external reference
17
+ */
18
+
19
+ import * as dotenv from 'dotenv'
20
+ import { app, appReady, getWallet, knex } from './app.js'
21
+ import { spawn } from 'child_process'
22
+ import { createServer } from 'http'
23
+ import { PublicKey } from '@bsv/sdk'
24
+ import { Logger, log } from './utils/logger.js'
25
+ import { trace, SpanStatusCode } from '@opentelemetry/api'
26
+ import { AuthSocketServer } from '@bsv/authsocket'
27
+ import * as crypto from 'crypto'
28
+ import { initializeFirebase } from './config/firebase.js'
29
+ (global.self as any) = { crypto }
30
+
31
+ dotenv.config()
32
+
33
+ // Load environment variables
34
+ const {
35
+ NODE_ENV = 'development',
36
+ PORT,
37
+ SERVER_PRIVATE_KEY,
38
+ ENABLE_WEBSOCKETS = 'true',
39
+ ROUTING_PREFIX = ''
40
+ } = process.env
41
+
42
+ // if (NODE_ENV === 'development' || process.env.LOGGING_ENABLED === 'true') {
43
+ // Logger.enable()
44
+ // }
45
+ Logger.enable()
46
+ // Determine which port to listen on
47
+ const parsedPort = Number(PORT)
48
+ const parsedEnvPort = Number(process.env.HTTP_PORT)
49
+
50
+ const HTTP_PORT: number = NODE_ENV !== 'development'
51
+ ? 3000
52
+ : !Number.isNaN(parsedPort) && parsedPort > 0
53
+ ? parsedPort
54
+ : !Number.isNaN(parsedEnvPort) && parsedEnvPort > 0
55
+ ? parsedEnvPort
56
+ : 8080
57
+
58
+ // Ensure private key is available before proceeding
59
+ if (SERVER_PRIVATE_KEY === undefined || SERVER_PRIVATE_KEY === null || SERVER_PRIVATE_KEY.trim() === '') {
60
+ throw new Error('SERVER_PRIVATE_KEY is not defined in the environment variables.')
61
+ }
62
+
63
+ // Initialize Firebase Admin (only when ENABLE_FIREBASE=true)
64
+ initializeFirebase()
65
+
66
+ // Create HTTP server
67
+ /* eslint-disable @typescript-eslint/no-misused-promises */
68
+ const http = createServer(app)
69
+
70
+ // WebSocket setup (only if enabled)
71
+ // Held in a const container so the exported binding is never reassigned.
72
+ const ioRef: { current: AuthSocketServer | null } = { current: null }
73
+
74
+ /**
75
+ * @function start
76
+ * @description
77
+ * Initializes the WebSocket server with identity-key-based authentication
78
+ * and attaches all supported event handlers for:
79
+ * - `sendMessage`
80
+ * - `joinRoom`
81
+ * - `leaveRoom`
82
+ * - `disconnect`
83
+ *
84
+ * Only runs if `ENABLE_WEBSOCKETS` is set to `true` in the environment.
85
+ *
86
+ * @returns {Promise<void>} Resolves once WebSocket listeners are fully attached.
87
+ */
88
+ export const start = async (): Promise<void> => {
89
+ await appReady
90
+
91
+ if (ENABLE_WEBSOCKETS.toLowerCase() === 'true') {
92
+ Logger.log('[WEBSOCKET] Initializing WebSocket support...')
93
+ ioRef.current = new AuthSocketServer(http, {
94
+ wallet: await getWallet(),
95
+ cors: {
96
+ origin: '*',
97
+ methods: ['GET', 'POST']
98
+ }
99
+ })
100
+
101
+ // Map to store authenticated identity keys
102
+ const authenticatedSockets = new Map<string, string>()
103
+
104
+ ioRef.current.on('connection', (socket) => {
105
+ Logger.log('[WEBSOCKET] New connection established.')
106
+
107
+ // Handle immediate authentication if identityKey is available
108
+ if (typeof socket.identityKey === 'string' && socket.identityKey.trim() !== '') {
109
+ try {
110
+ const parsedIdentityKey = PublicKey.fromString(socket.identityKey)
111
+ Logger.log('[DEBUG] Parsed WebSocket Identity Key Successfully:', parsedIdentityKey.toString())
112
+
113
+ authenticatedSockets.set(socket.id, parsedIdentityKey.toString())
114
+ Logger.log('[WEBSOCKET] Identity key stored for socket ID:', socket.id)
115
+
116
+ // Send confirmation immediately if identity key is provided on connection
117
+ void socket.emit('authenticationSuccess', { status: 'success' })
118
+ } catch (error) {
119
+ Logger.error('[ERROR] Failed to parse WebSocket identity key:', error)
120
+ }
121
+ } else {
122
+ // Wait for 'authenticated' event if identityKey was not in handshake
123
+ Logger.warn('[WARN] WebSocket connection received without identity key. Waiting for authentication...')
124
+
125
+ let identityKeyHandled = false
126
+
127
+ const authListener = async (data: { identityKey?: string }): Promise<void> => {
128
+ if (identityKeyHandled) return
129
+
130
+ Logger.log('[WEBSOCKET] Received authentication data:', data)
131
+
132
+ if (data !== null && data !== undefined && typeof data.identityKey === 'string' && data.identityKey.trim().length > 0) {
133
+ try {
134
+ const parsedIdentityKey = PublicKey.fromString(data.identityKey)
135
+ Logger.log('[DEBUG] Retrieved and parsed Identity Key after connection:', parsedIdentityKey.toString())
136
+
137
+ authenticatedSockets.set(socket.id, parsedIdentityKey.toString())
138
+ Logger.log('[WEBSOCKET] Stored authenticated Identity Key for socket ID:', socket.id)
139
+
140
+ identityKeyHandled = true
141
+
142
+ Logger.log(`New authenticated WebSocket connection from: ${authenticatedSockets.get(socket.id) ?? 'unknown'}`)
143
+
144
+ // Emit authentication success message
145
+ await socket.emit('authenticationSuccess', { status: 'success' }).catch(error => {
146
+ Logger.error('[WEBSOCKET ERROR] Failed to send authentication success event:', error)
147
+ })
148
+ } catch (error) {
149
+ Logger.error('[ERROR] Failed to parse Identity Key from authenticated event:', error)
150
+ await socket.emit('authenticationFailed', { reason: 'Invalid identity key format' })
151
+ }
152
+ } else {
153
+ Logger.warn('[WARN] Invalid or missing identity key in authentication event.')
154
+ await socket.emit('authenticationFailed', { reason: 'Missing identity key' })
155
+ }
156
+ }
157
+
158
+ // Ensure `authListener` is used properly
159
+ socket.on('authenticated', authListener)
160
+ }
161
+
162
+ // Handle sendMessage over WebSocket
163
+ socket.on(
164
+ 'sendMessage',
165
+ async (data: { roomId: string, message: { messageId: string, recipient: string, body: string } }): Promise<void> => {
166
+ if (typeof data !== 'object' || data == null) {
167
+ Logger.error('[WEBSOCKET ERROR] Invalid data object received.')
168
+ await socket.emit('messageFailed', { reason: 'Invalid data object' })
169
+ return
170
+ }
171
+
172
+ const { roomId, message } = data
173
+
174
+ if (!authenticatedSockets.has(socket.id)) {
175
+ Logger.warn('[WEBSOCKET] Unauthorized attempt to send a message.')
176
+ await socket.emit('paymentFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
177
+ return
178
+ }
179
+
180
+ Logger.log(`[WEBSOCKET] Processing sendMessage for room: ${roomId}`)
181
+
182
+ try {
183
+ if (typeof roomId !== 'string' || roomId.trim() === '') {
184
+ Logger.error('[WEBSOCKET ERROR] Invalid roomId:', roomId)
185
+ await socket.emit('messageFailed', { reason: 'Invalid room ID' })
186
+ return
187
+ }
188
+
189
+ if (typeof message !== 'object' || message == null) {
190
+ Logger.error('[WEBSOCKET ERROR] Invalid message object:', message)
191
+ await socket.emit('messageFailed', { reason: 'Invalid message object' })
192
+ return
193
+ }
194
+
195
+ if (typeof message.body !== 'string' || message.body.trim() === '') {
196
+ Logger.error('[WEBSOCKET ERROR] Invalid message body:', message.body)
197
+ await socket.emit('messageFailed', { reason: 'Invalid message body' })
198
+ return
199
+ }
200
+
201
+ Logger.log(`[WEBSOCKET] Acknowledging message ${message.messageId} to sender.`)
202
+
203
+ const ackPayload = {
204
+ status: 'success',
205
+ messageId: message.messageId
206
+ }
207
+
208
+ Logger.log(`[WEBSOCKET] Emitting ack event: sendMessageAck-${roomId}`)
209
+
210
+ socket.emit(`sendMessageAck-${roomId}`, ackPayload).catch((error) => {
211
+ Logger.error(`[WEBSOCKET ERROR] Failed to emit sendMessageAck-${roomId}:`, error)
212
+ })
213
+
214
+ // Store message in the database just like HTTP sendMessage route
215
+ try {
216
+ const parts = roomId.split('-')
217
+ const messageBoxType = parts.length > 1 ? parts[1] : 'default'
218
+
219
+ Logger.log(`[WEBSOCKET] Parsed messageBoxType: ${messageBoxType}`)
220
+ Logger.log(`[WEBSOCKET] Attempting to store message for recipient: ${message.recipient}, box type: ${messageBoxType}`)
221
+
222
+ let messageBox = await knex('messageBox')
223
+ .where({ identityKey: message.recipient, type: messageBoxType })
224
+ .first()
225
+
226
+ if (messageBox === null || messageBox === undefined) {
227
+ Logger.log('[WEBSOCKET] messageBox not found. Creating new messageBox.')
228
+ await knex('messageBox').insert({
229
+ identityKey: message.recipient,
230
+ type: messageBoxType,
231
+ created_at: new Date(),
232
+ updated_at: new Date()
233
+ })
234
+ }
235
+
236
+ messageBox = await knex('messageBox')
237
+ .where({ identityKey: message.recipient, type: messageBoxType })
238
+ .select('messageBoxId')
239
+ .first()
240
+
241
+ const messageBoxId = messageBox?.messageBoxId ?? null
242
+
243
+ if (messageBoxId === null || messageBoxId === undefined) {
244
+ Logger.warn('[WEBSOCKET WARNING] messageBoxId is null — message may not be stored correctly!')
245
+ } else {
246
+ Logger.log(`[WEBSOCKET] Resolved messageBoxId: ${String(messageBoxId)}`)
247
+ }
248
+
249
+ const senderKey = authenticatedSockets.get(socket.id) ?? null
250
+
251
+ const insertResult = await knex('messages')
252
+ .insert({
253
+ messageId: message.messageId,
254
+ messageBoxId,
255
+ sender: senderKey,
256
+ recipient: message.recipient,
257
+ body: message.body,
258
+ created_at: new Date(),
259
+ updated_at: new Date()
260
+ })
261
+ .onConflict('messageId')
262
+ .ignore()
263
+
264
+ if (insertResult.length === 0) {
265
+ Logger.warn('[WEBSOCKET WARNING] Message insert was ignored due to conflict (duplicate messageId?)')
266
+ } else {
267
+ Logger.log('[WEBSOCKET] Message successfully stored in DB.')
268
+ }
269
+ } catch (dbError) {
270
+ Logger.error('[WEBSOCKET ERROR] Failed to store message in DB:', dbError)
271
+ await socket.emit('messageFailed', { reason: 'Failed to store message' })
272
+ return
273
+ }
274
+
275
+ if (ioRef.current === null) {
276
+ Logger.error('[WEBSOCKET ERROR] io is null, cannot emit message.')
277
+ return
278
+ }
279
+ Logger.log(`[WEBSOCKET] Emitting message to room ${roomId}`)
280
+ ioRef.current.emit(`sendMessage-${roomId}`, {
281
+ sender: authenticatedSockets.get(socket.id),
282
+ messageId: message.messageId,
283
+ body: message.body
284
+ })
285
+ } catch (error) {
286
+ Logger.error('[WEBSOCKET ERROR] Unexpected failure in sendMessage handler:', error)
287
+ await socket.emit('messageFailed', { reason: 'Unexpected error occurred' })
288
+ }
289
+ }
290
+ )
291
+
292
+ // Handle joining/leaving rooms
293
+ socket.on('joinRoom', async (roomId: string) => {
294
+ if (!authenticatedSockets.has(socket.id)) {
295
+ Logger.warn('[WEBSOCKET] Unauthorized attempt to join a room.')
296
+ await socket.emit('joinFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
297
+ return
298
+ }
299
+
300
+ if (roomId == null || typeof roomId !== 'string' || roomId.trim() === '') {
301
+ Logger.error('[WEBSOCKET ERROR] Invalid roomId:', roomId)
302
+ await socket.emit('joinFailed', { reason: 'Invalid room ID' })
303
+ return
304
+ }
305
+
306
+ Logger.log(`[WEBSOCKET] User ${socket.id} joined room ${roomId}`)
307
+ await socket.emit('joinedRoom', { roomId })
308
+ })
309
+
310
+ socket.on('leaveRoom', async (roomId: string) => {
311
+ if (!authenticatedSockets.has(socket.id)) {
312
+ Logger.warn('[WEBSOCKET] Unauthorized attempt to leave a room.')
313
+ await socket.emit('leaveFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
314
+ return
315
+ }
316
+
317
+ if (roomId == null || roomId === '' || typeof roomId !== 'string' || roomId.trim() === '') {
318
+ Logger.error('[WEBSOCKET ERROR] Invalid roomId:', roomId)
319
+ await socket.emit('leaveFailed', { reason: 'Invalid room ID' })
320
+ return
321
+ }
322
+
323
+ Logger.log(`[WEBSOCKET] User ${socket.id} left room ${roomId}`)
324
+ await socket.emit('leftRoom', { roomId })
325
+ })
326
+
327
+ // Clean up on disconnect
328
+ socket.on('disconnect', (reason: string) => {
329
+ Logger.log(`[WEBSOCKET] Disconnected: ${reason}`)
330
+ authenticatedSockets.delete(socket.id)
331
+ })
332
+ })
333
+ }
334
+ }
335
+
336
+ // Export for testing and CLI use.
337
+ // `ioRef` is a const container; the live WebSocket server is at `ioRef.current`.
338
+ export { ioRef as io, http, HTTP_PORT, ROUTING_PREFIX }
339
+
340
+ // Only run server if not in test mode
341
+ if (NODE_ENV !== 'test') {
342
+ const tracer = trace.getTracer('@bsv/messagebox-server')
343
+ http.listen(HTTP_PORT, () => {
344
+ log.info({ operation: 'listen', outcome: 'ok', port: HTTP_PORT }, 'MessageBox listening')
345
+
346
+ // Run DB migrations immediately, no delay needed with container healthchecks
347
+ ; tracer.startActiveSpan('messagebox.migrate', async (span) => {
348
+ const startedAt = Date.now()
349
+ try {
350
+ await knex.migrate.latest()
351
+ span.setStatus({ code: SpanStatusCode.OK })
352
+ log.info({ operation: 'migrate', outcome: 'ok', duration_ms: Date.now() - startedAt }, 'migrations applied')
353
+ } catch (error) {
354
+ span.recordException(error as Error)
355
+ span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message })
356
+ log.error({ operation: 'migrate', outcome: 'error', err: error }, '[STARTUP ERROR] migrations failed')
357
+ } finally {
358
+ span.end()
359
+ }
360
+ })
361
+ })
362
+
363
+ start().catch(error => {
364
+ log.error({ operation: 'server.init', outcome: 'error', err: error }, '[SERVER INIT ERROR]')
365
+ })
366
+ }
367
+
368
+ // Composable API for embedding (standalone boot above is unchanged)
369
+ export {
370
+ createMessageBoxContext,
371
+ createMessageBoxApp,
372
+ mountMessageBoxRoutes,
373
+ attachMessageBoxWebSockets
374
+ } from './compose.js'
375
+ export type { MessageBoxContext, CreateMessageBoxContextOptions } from './context.js'
@@ -0,0 +1,158 @@
1
+ /* eslint-env jest */
2
+ import acknowledgeMessage, { AcknowledgeRequest } from '../acknowledgeMessage.js'
3
+ import mockKnex, { Tracker } from 'mock-knex'
4
+ import { Response } from 'express'
5
+ import knexLib from 'knex'
6
+ import knexConfig from '../../../knexfile.js'
7
+ import { bindMessageBoxRuntime } from '../../runtimeDeps.js'
8
+
9
+ const testKnex = (knexLib as any).default?.(knexConfig.development) ??
10
+ (knexLib as any)(knexConfig.development)
11
+ bindMessageBoxRuntime({ knex: testKnex })
12
+ const knex = acknowledgeMessage.knex
13
+ let queryTracker: Tracker
14
+
15
+ // Define Mock Express Response Object
16
+ const mockRes: jest.Mocked<Response> = {
17
+ status: jest.fn().mockReturnThis(),
18
+ json: jest.fn().mockReturnThis(),
19
+ sendStatus: jest.fn().mockReturnThis(),
20
+ send: jest.fn().mockReturnThis(),
21
+ end: jest.fn().mockReturnThis(),
22
+ setHeader: jest.fn().mockReturnThis(),
23
+ getHeader: jest.fn(),
24
+ getHeaders: jest.fn(),
25
+ header: jest.fn().mockReturnThis(),
26
+ type: jest.fn().mockReturnThis(),
27
+ format: jest.fn(),
28
+ location: jest.fn().mockReturnThis(),
29
+ redirect: jest.fn().mockReturnThis(),
30
+ append: jest.fn().mockReturnThis(),
31
+ render: jest.fn(),
32
+ vary: jest.fn().mockReturnThis(),
33
+ cookie: jest.fn().mockReturnThis(),
34
+ clearCookie: jest.fn().mockReturnThis()
35
+ } as unknown as jest.Mocked<Response>
36
+
37
+ let validReq: AcknowledgeRequest
38
+
39
+ describe('acknowledgeMessage', () => {
40
+ beforeAll(() => {
41
+ (mockKnex as any).mock(knex)
42
+ })
43
+
44
+ beforeEach(() => {
45
+ jest.spyOn(console, 'error').mockImplementation(() => { })
46
+
47
+ queryTracker = (mockKnex as any).getTracker() as Tracker
48
+ queryTracker.install()
49
+
50
+ validReq = {
51
+ auth: {
52
+ identityKey: 'mockIdKey'
53
+ },
54
+ body: {
55
+ messageIds: ['123']
56
+ },
57
+ get: jest.fn(),
58
+ header: jest.fn()
59
+ } as unknown as AcknowledgeRequest
60
+ })
61
+
62
+ afterEach(() => {
63
+ jest.clearAllMocks()
64
+
65
+ if (queryTracker !== null && queryTracker !== undefined) {
66
+ queryTracker.uninstall()
67
+ }
68
+ })
69
+
70
+ afterAll(() => {
71
+ (mockKnex as any).unmock(knex)
72
+ })
73
+
74
+ it('Throws an error if messageId is missing', async () => {
75
+ delete validReq.body.messageIds
76
+ await acknowledgeMessage.func(validReq, mockRes as Response)
77
+ expect(mockRes.status).toHaveBeenCalledWith(400)
78
+ expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
79
+ status: 'error',
80
+ code: 'ERR_MESSAGE_ID_REQUIRED'
81
+ }))
82
+ })
83
+
84
+ it('Throws an error if messageIds is not an Array', async () => {
85
+ validReq.body.messageIds = '24' as unknown as string[]
86
+
87
+ await acknowledgeMessage.func(validReq, mockRes as Response)
88
+
89
+ expect(mockRes.status).toHaveBeenCalledWith(400)
90
+ expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
91
+ status: 'error',
92
+ code: 'ERR_INVALID_MESSAGE_ID',
93
+ description: 'Message IDs must be formatted as an array of strings!'
94
+ }))
95
+ }, 7000)
96
+
97
+ it('Deletes a message', async () => {
98
+ queryTracker.on('query', (q, s) => {
99
+ if (s === 1) {
100
+ expect(q.method).toEqual('del')
101
+ expect(q.sql).toEqual(
102
+ 'delete from `messages` where `recipient` = ? and `messageId` in (?)'
103
+ )
104
+ expect(q.bindings).toEqual([
105
+ 'mockIdKey',
106
+ '123'
107
+ ])
108
+ q.response(true)
109
+ } else {
110
+ q.response([])
111
+ }
112
+ })
113
+
114
+ await acknowledgeMessage.func(validReq, mockRes as Response)
115
+ expect(mockRes.status).toHaveBeenCalledWith(200)
116
+ expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
117
+ status: 'success'
118
+ }))
119
+ })
120
+
121
+ it('Throws an error if deletion fails', async () => {
122
+ queryTracker.on('query', (q, step) => {
123
+ if (step === 1) {
124
+ expect(q.method).toEqual('del')
125
+ expect(q.sql).toEqual('delete from `messages` where `recipient` = ? and `messageId` in (?)')
126
+ expect(q.bindings).toEqual(['mockIdKey', '123'])
127
+
128
+ q.response(0) // Simulate deletion failure
129
+ } else {
130
+ q.response([]) // Prevent test from hanging due to unhandled second query
131
+ }
132
+ })
133
+
134
+ await acknowledgeMessage.func(validReq, mockRes as Response)
135
+
136
+ expect(mockRes.status).toHaveBeenCalledWith(400)
137
+ expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
138
+ status: 'error',
139
+ code: 'ERR_INVALID_ACKNOWLEDGMENT',
140
+ description: 'Message not found!'
141
+ }))
142
+ }, 7000)
143
+
144
+ it('Throws unknown errors', async () => {
145
+ queryTracker.on('query', () => {
146
+ throw new Error('Failed')
147
+ })
148
+
149
+ await acknowledgeMessage.func(validReq, mockRes as Response)
150
+
151
+ expect(mockRes.status).toHaveBeenCalledWith(500)
152
+ expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({
153
+ status: 'error',
154
+ code: 'ERR_INTERNAL_ERROR',
155
+ description: 'An internal error has occurred while acknowledging the message'
156
+ }))
157
+ })
158
+ })