@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/app.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @file app.ts
3
+ * @description
4
+ * Initializes the MessageBoxServer Express app.
5
+ *
6
+ * Responsibilities:
7
+ * - Parses environment variables and loads config
8
+ * - Sets up Knex for DB access
9
+ * - Initializes WalletClient from the BSV SDK
10
+ * - Mounts Express routes (pre-auth and post-auth)
11
+ * - Applies auth middleware using wallet identity
12
+ *
13
+ * This file exports:
14
+ * - `app`: the configured Express instance
15
+ * - `walletReady`: a promise that resolves once the wallet is ready
16
+ * - `getWallet()`: async accessor for the WalletClient
17
+ * - `useRoutes()`: middleware + route initialization
18
+ * - `appReady`: promise that completes once all setup is done
19
+ */
20
+
21
+ import * as dotenv from 'dotenv'
22
+ import express, {
23
+ Express,
24
+ Request as ExpressRequest,
25
+ Response,
26
+ NextFunction,
27
+ RequestHandler
28
+ } from 'express'
29
+ import bodyParser from 'body-parser'
30
+ import { preAuth, postAuth } from './routes/index.js'
31
+ import { Logger } from './utils/logger.js'
32
+ import sendMessageRoute from './routes/sendMessage.js'
33
+ import { Setup } from '@bsv/wallet-toolbox'
34
+ import knexLib, { Knex } from 'knex'
35
+ import knexConfig from '../knexfile.js'
36
+ import type { WalletInterface } from '@bsv/sdk'
37
+ import { createAuthMiddleware } from '@bsv/auth-express-middleware'
38
+ import { createPaymentMiddleware } from '@bsv/payment-express-middleware'
39
+ import { setupSwagger } from './swagger.js'
40
+ import { bindMessageBoxRuntime } from './runtimeDeps.js'
41
+ import * as crypto from 'crypto'
42
+ (global.self as any) = { crypto }
43
+
44
+ dotenv.config()
45
+
46
+ // Create the Express app instance
47
+ export const app: Express = express()
48
+
49
+ // Load environment variables
50
+ const {
51
+ NODE_ENV = 'development',
52
+ ROUTING_PREFIX = '',
53
+ SERVER_PRIVATE_KEY,
54
+ WALLET_STORAGE_URL,
55
+ BSV_NETWORK = 'mainnet'
56
+ } = process.env
57
+
58
+ // Enable logger in dev mode or if explicitly enabled
59
+ if (NODE_ENV === 'development' || process.env.LOGGING_ENABLED === 'true') {
60
+ Logger.enable()
61
+ }
62
+
63
+ /**
64
+ * Knex instance connected based on environment (development, production, or staging).
65
+ */
66
+ export const knex: Knex = (knexLib as any).default?.(
67
+ NODE_ENV === 'production' || NODE_ENV === 'staging'
68
+ ? knexConfig.production
69
+ : knexConfig.development
70
+ ) ?? (knexLib as any)(
71
+ NODE_ENV === 'production' || NODE_ENV === 'staging'
72
+ ? knexConfig.production
73
+ : knexConfig.development
74
+ )
75
+
76
+ // Wallet initialization logic
77
+ let _wallet: WalletInterface | undefined
78
+ let _resolveReady: () => void
79
+ export const walletReady = new Promise<void>((resolve) => {
80
+ _resolveReady = resolve
81
+ })
82
+
83
+ /**
84
+ * @function initializeWallet
85
+ * @description Initializes the WalletClient with a root identity key and storage backend.
86
+ *
87
+ * Loads configuration from the environment and connects to the wallet service.
88
+ *
89
+ * @returns {Promise<void>} Resolves when the wallet is initialized.
90
+ * @throws If SERVER_PRIVATE_KEY is missing or invalid.
91
+ */
92
+ export async function initializeWallet (): Promise<void> {
93
+ if (SERVER_PRIVATE_KEY == null || SERVER_PRIVATE_KEY.trim() === '') {
94
+ throw new Error('SERVER_PRIVATE_KEY is not defined in environment variables.')
95
+ }
96
+
97
+ _wallet = await Setup.createWalletClientNoEnv({
98
+ chain: BSV_NETWORK === 'testnet' ? 'test' : 'main',
99
+ rootKeyHex: SERVER_PRIVATE_KEY,
100
+ storageUrl: WALLET_STORAGE_URL
101
+ })
102
+
103
+ bindMessageBoxRuntime({ knex, wallet: _wallet })
104
+ _resolveReady()
105
+ }
106
+
107
+ /**
108
+ * @function getWallet
109
+ * @description Waits for the WalletClient to be ready and returns the instance.
110
+ *
111
+ * @returns {Promise<WalletInterface>} The initialized wallet client
112
+ * @throws {Error} If called before the wallet is initialized
113
+ */
114
+ export async function getWallet (): Promise<WalletInterface> {
115
+ await walletReady
116
+ if (_wallet == null) {
117
+ throw new Error('Wallet has not been initialized yet.')
118
+ }
119
+ return _wallet
120
+ }
121
+
122
+ // Run on app startup to prep wallet and activate routes
123
+ export const appReady = (async () => {
124
+ await initializeWallet()
125
+ await useRoutes()
126
+ })()
127
+
128
+ /**
129
+ * @function useRoutes
130
+ * @description Registers all routes and middleware on the Express app instance.
131
+ *
132
+ * Steps:
133
+ * - Applies JSON body parser
134
+ * - Enables CORS headers for all routes
135
+ * - Waits for WalletClient to initialize
136
+ * - Adds authentication middleware
137
+ * - Mounts pre-auth and post-auth route handlers
138
+ *
139
+ * @returns {Promise<void>} Once all middleware and routes are mounted
140
+ * @throws If wallet is not available when needed
141
+ */
142
+ export async function useRoutes (): Promise<void> {
143
+ // Parse incoming JSON bodies with a high limit
144
+ app.use(bodyParser.json({ limit: '1gb', type: 'application/json' }))
145
+
146
+ // CORS setup
147
+ app.use((req, res, next) => {
148
+ res.header('Access-Control-Allow-Origin', '*')
149
+ res.header('Access-Control-Allow-Headers', '*')
150
+ res.header('Access-Control-Allow-Methods', '*')
151
+ res.header('Access-Control-Expose-Headers', '*')
152
+ res.header('Access-Control-Allow-Private-Network', 'true')
153
+
154
+ if (req.method === 'OPTIONS') {
155
+ res.sendStatus(200)
156
+ } else {
157
+ next()
158
+ }
159
+ })
160
+
161
+ // Enable Swagger docs
162
+ setupSwagger(app)
163
+
164
+ await walletReady
165
+ if (_wallet == null) {
166
+ throw new Error('Wallet is not initialized for auth middleware')
167
+ }
168
+
169
+ app.use(
170
+ createAuthMiddleware({
171
+ wallet: _wallet,
172
+ logger: console
173
+ })
174
+ )
175
+
176
+ app.use(
177
+ createPaymentMiddleware({
178
+ wallet: _wallet,
179
+ calculateRequestPrice: async (req: Request) => {
180
+ if (req.url.includes('/sendMessage')) {
181
+ // TODO: Configure a custom price calculation as needed.
182
+ }
183
+ return 0
184
+ }
185
+ })
186
+ )
187
+
188
+ // Register pre-authentication routes (no auth required)
189
+ preAuth.forEach((route) => {
190
+ app[route.type as 'get' | 'post' | 'put' | 'delete'](
191
+ `${String(ROUTING_PREFIX)}${String(route.path)}`,
192
+ route.func as unknown as (req: ExpressRequest, res: Response, next: NextFunction) => void
193
+ )
194
+ })
195
+
196
+ // Register post-authentication routes (requires auth header)
197
+ postAuth.forEach((route) => {
198
+ if (route.path === '/sendMessage') {
199
+ app[route.type as 'get' | 'post' | 'put' | 'delete'](
200
+ `${ROUTING_PREFIX}${route.path}`,
201
+ sendMessageRoute.func as unknown as RequestHandler
202
+ )
203
+ } else {
204
+ app[route.type as 'get' | 'post' | 'put' | 'delete'](
205
+ `${ROUTING_PREFIX}${route.path}`,
206
+ route.func as RequestHandler
207
+ )
208
+ }
209
+ })
210
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Composable entrypoints for embedding messagebox in a parent app.
3
+ * Standalone binary continues to use index.ts → app.ts as before.
4
+ */
5
+ import express, {
6
+ type Express,
7
+ type Request as ExpressRequest,
8
+ type Response,
9
+ type NextFunction,
10
+ type RequestHandler,
11
+ Router
12
+ } from 'express'
13
+ import bodyParser from 'body-parser'
14
+ import type { Server as HttpServer } from 'node:http'
15
+ import { PublicKey } from '@bsv/sdk'
16
+ import { createAuthMiddleware } from '@bsv/auth-express-middleware'
17
+ import { createPaymentMiddleware } from '@bsv/payment-express-middleware'
18
+ import { AuthSocketServer } from '@bsv/authsocket'
19
+ import { preAuth, postAuth } from './routes/index.js'
20
+ import sendMessageRoute from './routes/sendMessage.js'
21
+ import { setupSwagger } from './swagger.js'
22
+ import { Logger } from './utils/logger.js'
23
+ import { bindMessageBoxRuntime } from './runtimeDeps.js'
24
+ import type { MessageBoxContext } from './context.js'
25
+
26
+ export { createMessageBoxContext } from './context.js'
27
+ export type { MessageBoxContext, CreateMessageBoxContextOptions } from './context.js'
28
+
29
+ type HttpMethod = 'get' | 'post' | 'put' | 'delete'
30
+
31
+
32
+ export function createMessageBoxApp (): Express {
33
+ return express()
34
+ }
35
+
36
+ /**
37
+ * Mount messagebox HTTP routes on an Express app.
38
+ * Auth/payment middleware apply only to this router (not the parent app).
39
+ */
40
+ export function mountMessageBoxRoutes (app: Express, ctx: MessageBoxContext): void {
41
+ bindMessageBoxRuntime({ knex: ctx.knex, wallet: ctx.wallet })
42
+
43
+ const router = Router()
44
+ router.use(bodyParser.json({ limit: '1gb', type: 'application/json' }))
45
+ router.use((req, res, next) => {
46
+ res.header('Access-Control-Allow-Origin', '*')
47
+ res.header('Access-Control-Allow-Headers', '*')
48
+ res.header('Access-Control-Allow-Methods', '*')
49
+ res.header('Access-Control-Expose-Headers', '*')
50
+ res.header('Access-Control-Allow-Private-Network', 'true')
51
+ if (req.method === 'OPTIONS') {
52
+ res.sendStatus(200)
53
+ } else {
54
+ next()
55
+ }
56
+ })
57
+
58
+ if (ctx.enableSwagger) {
59
+ setupSwagger(app)
60
+ }
61
+
62
+ preAuth.forEach((route) => {
63
+ router[route.type as HttpMethod](
64
+ route.path,
65
+ route.func as unknown as (req: ExpressRequest, res: Response, next: NextFunction) => void
66
+ )
67
+ })
68
+
69
+ router.use(
70
+ createAuthMiddleware({
71
+ wallet: ctx.wallet,
72
+ logger: ctx.logger
73
+ })
74
+ )
75
+
76
+ router.use(
77
+ createPaymentMiddleware({
78
+ wallet: ctx.wallet,
79
+ calculateRequestPrice: async (req) =>
80
+ await Promise.resolve(ctx.calculateRequestPrice(req as unknown as ExpressRequest))
81
+ })
82
+ )
83
+
84
+ postAuth.forEach((route) => {
85
+ const method = route.type as HttpMethod
86
+ if (route.path === '/sendMessage') {
87
+ router[method](route.path, sendMessageRoute.func as unknown as RequestHandler)
88
+ } else {
89
+ router[method](route.path, route.func as RequestHandler)
90
+ }
91
+ })
92
+
93
+ const prefix = ctx.routingPrefix ?? ''
94
+ app.use(prefix === '' ? '/' : prefix, router)
95
+ }
96
+
97
+ /**
98
+ * Attach authenticated WebSocket handlers. Mirrors standalone index.ts behavior
99
+ * with injected wallet/knex instead of module singletons.
100
+ */
101
+ export function attachMessageBoxWebSockets (
102
+ httpServer: HttpServer,
103
+ ctx: MessageBoxContext
104
+ ): AuthSocketServer | null {
105
+ if (!ctx.enableWebSockets) {
106
+ return null
107
+ }
108
+
109
+ bindMessageBoxRuntime({ knex: ctx.knex, wallet: ctx.wallet })
110
+
111
+ Logger.log('[WEBSOCKET] Initializing WebSocket support...')
112
+
113
+ const io = new AuthSocketServer(httpServer, {
114
+ wallet: ctx.wallet,
115
+ cors: {
116
+ origin: '*',
117
+ methods: ['GET', 'POST']
118
+ }
119
+ })
120
+
121
+ const knex = ctx.knex
122
+ const authenticatedSockets = new Map<string, string>()
123
+
124
+ io.on('connection', (socket) => {
125
+ Logger.log('[WEBSOCKET] New connection established.')
126
+
127
+ if (typeof socket.identityKey === 'string' && socket.identityKey.trim() !== '') {
128
+ try {
129
+ const parsedIdentityKey = PublicKey.fromString(socket.identityKey)
130
+ Logger.log('[DEBUG] Parsed WebSocket Identity Key Successfully:', parsedIdentityKey.toString())
131
+ authenticatedSockets.set(socket.id, parsedIdentityKey.toString())
132
+ Logger.log('[WEBSOCKET] Identity key stored for socket ID:', socket.id)
133
+ void socket.emit('authenticationSuccess', { status: 'success' })
134
+ } catch (error) {
135
+ Logger.error('[ERROR] Failed to parse WebSocket identity key:', error)
136
+ }
137
+ } else {
138
+ Logger.warn('[WARN] WebSocket connection received without identity key. Waiting for authentication...')
139
+ let identityKeyHandled = false
140
+ const authListener = async (data: { identityKey?: string }): Promise<void> => {
141
+ if (identityKeyHandled) return
142
+ Logger.log('[WEBSOCKET] Received authentication data:', data)
143
+ if (data?.identityKey != null && data.identityKey.trim().length > 0) {
144
+ try {
145
+ const parsedIdentityKey = PublicKey.fromString(data.identityKey)
146
+ authenticatedSockets.set(socket.id, parsedIdentityKey.toString())
147
+ identityKeyHandled = true
148
+ await socket.emit('authenticationSuccess', { status: 'success' }).catch(error => {
149
+ Logger.error('[WEBSOCKET ERROR] Failed to send authentication success event:', error)
150
+ })
151
+ } catch (error) {
152
+ Logger.error('[ERROR] Failed to parse Identity Key from authenticated event:', error)
153
+ await socket.emit('authenticationFailed', { reason: 'Invalid identity key format' })
154
+ }
155
+ } else {
156
+ Logger.warn('[WARN] Invalid or missing identity key in authentication event.')
157
+ await socket.emit('authenticationFailed', { reason: 'Missing identity key' })
158
+ }
159
+ }
160
+ socket.on('authenticated', authListener)
161
+ }
162
+
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
+ const { roomId, message } = data
172
+ if (!authenticatedSockets.has(socket.id)) {
173
+ Logger.warn('[WEBSOCKET] Unauthorized attempt to send a message.')
174
+ await socket.emit('paymentFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
175
+ return
176
+ }
177
+ try {
178
+ if (typeof roomId !== 'string' || roomId.trim() === '') {
179
+ await socket.emit('messageFailed', { reason: 'Invalid room ID' })
180
+ return
181
+ }
182
+ if (typeof message !== 'object' || message == null) {
183
+ await socket.emit('messageFailed', { reason: 'Invalid message object' })
184
+ return
185
+ }
186
+ if (typeof message.body !== 'string' || message.body.trim() === '') {
187
+ await socket.emit('messageFailed', { reason: 'Invalid message body' })
188
+ return
189
+ }
190
+
191
+ socket.emit(`sendMessageAck-${roomId}`, {
192
+ status: 'success',
193
+ messageId: message.messageId
194
+ }).catch((error) => {
195
+ Logger.error(`[WEBSOCKET ERROR] Failed to emit sendMessageAck-${roomId}:`, error)
196
+ })
197
+
198
+ try {
199
+ const parts = roomId.split('-')
200
+ const messageBoxType = parts.length > 1 ? parts[1] : 'default'
201
+
202
+ let messageBox = await knex('messageBox')
203
+ .where({ identityKey: message.recipient, type: messageBoxType })
204
+ .first()
205
+
206
+ if (messageBox == null) {
207
+ await knex('messageBox').insert({
208
+ identityKey: message.recipient,
209
+ type: messageBoxType,
210
+ created_at: new Date(),
211
+ updated_at: new Date()
212
+ })
213
+ }
214
+
215
+ messageBox = await knex('messageBox')
216
+ .where({ identityKey: message.recipient, type: messageBoxType })
217
+ .select('messageBoxId')
218
+ .first()
219
+
220
+ const messageBoxId = messageBox?.messageBoxId ?? null
221
+ const senderKey = authenticatedSockets.get(socket.id) ?? null
222
+
223
+ await knex('messages')
224
+ .insert({
225
+ messageId: message.messageId,
226
+ messageBoxId,
227
+ sender: senderKey,
228
+ recipient: message.recipient,
229
+ body: message.body,
230
+ created_at: new Date(),
231
+ updated_at: new Date()
232
+ })
233
+ .onConflict('messageId')
234
+ .ignore()
235
+ } catch (dbError) {
236
+ Logger.error('[WEBSOCKET ERROR] Failed to store message in DB:', dbError)
237
+ await socket.emit('messageFailed', { reason: 'Failed to store message' })
238
+ return
239
+ }
240
+
241
+ io.emit(`sendMessage-${roomId}`, {
242
+ sender: authenticatedSockets.get(socket.id),
243
+ messageId: message.messageId,
244
+ body: message.body
245
+ })
246
+ } catch (error) {
247
+ Logger.error('[WEBSOCKET ERROR] Unexpected failure in sendMessage handler:', error)
248
+ await socket.emit('messageFailed', { reason: 'Unexpected error occurred' })
249
+ }
250
+ }
251
+ )
252
+
253
+ socket.on('joinRoom', async (roomId: string) => {
254
+ if (!authenticatedSockets.has(socket.id)) {
255
+ await socket.emit('joinFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
256
+ return
257
+ }
258
+ if (typeof roomId !== 'string' || roomId.trim() === '') {
259
+ await socket.emit('joinFailed', { reason: 'Invalid room ID' })
260
+ return
261
+ }
262
+ Logger.log(`[WEBSOCKET] User ${socket.id} joined room ${roomId}`)
263
+ await socket.emit('joinedRoom', { roomId })
264
+ })
265
+
266
+ socket.on('leaveRoom', async (roomId: string) => {
267
+ if (!authenticatedSockets.has(socket.id)) {
268
+ await socket.emit('leaveFailed', { reason: 'Unauthorized: WebSocket not authenticated' })
269
+ return
270
+ }
271
+ if (typeof roomId !== 'string' || roomId.trim() === '') {
272
+ await socket.emit('leaveFailed', { reason: 'Invalid room ID' })
273
+ return
274
+ }
275
+ Logger.log(`[WEBSOCKET] User ${socket.id} left room ${roomId}`)
276
+ await socket.emit('leftRoom', { roomId })
277
+ })
278
+
279
+ socket.on('disconnect', (reason: string) => {
280
+ Logger.log(`[WEBSOCKET] Disconnected: ${reason}`)
281
+ authenticatedSockets.delete(socket.id)
282
+ })
283
+ })
284
+
285
+ return io
286
+ }
@@ -0,0 +1,183 @@
1
+ import { initializeApp, getApps, getApp, cert, applicationDefault, type App } from 'firebase-admin/app'
2
+ import { getMessaging, type Messaging, type Message } from 'firebase-admin/messaging'
3
+ import { getFirestore, type Firestore } from 'firebase-admin/firestore'
4
+ import * as path from 'path'
5
+ import dotenv from 'dotenv'
6
+ import { log } from '../utils/logger.js'
7
+
8
+ dotenv.config()
9
+
10
+ let firebaseApp: App | null = null
11
+
12
+ /**
13
+ * Initialize Firebase Admin SDK.
14
+ * Returns null (and logs a warning) when ENABLE_FIREBASE is not 'true',
15
+ * or when FIREBASE_PROJECT_ID is absent, so the server can run without Firebase.
16
+ */
17
+ export function initializeFirebase(): App | null {
18
+ const enableFirebase = process.env.ENABLE_FIREBASE
19
+
20
+ if (enableFirebase !== 'true') {
21
+ log.info({ operation: 'firebase.init', enable_firebase: enableFirebase ?? 'unset' }, 'Firebase disabled, skipping initialization')
22
+ return null
23
+ }
24
+
25
+ if (firebaseApp != null) {
26
+ log.info({ operation: 'firebase.init' }, 'Firebase already initialized')
27
+ return firebaseApp
28
+ }
29
+
30
+ try {
31
+ const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
32
+ const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH
33
+ const projectId = process.env.FIREBASE_PROJECT_ID
34
+
35
+ if (projectId == null || projectId === '') {
36
+ throw new Error('FIREBASE_PROJECT_ID environment variable is required')
37
+ }
38
+
39
+ let firebaseCredential: any // Will be assigned based on auth method
40
+
41
+ if (serviceAccountJson != null && serviceAccountJson !== '') {
42
+ log.info({ operation: 'firebase.init', credential_source: 'env' }, 'Using Firebase service account from environment variable')
43
+ try {
44
+ log.debug({ operation: 'firebase.init', service_account_json_length: serviceAccountJson.length }, 'Service account JSON length')
45
+
46
+ // Debug credential functions
47
+ log.debug({ operation: 'firebase.init', cert_function_type: typeof cert }, 'cert function type')
48
+ log.debug({ operation: 'firebase.init', application_default_function_type: typeof applicationDefault }, 'applicationDefault function type')
49
+
50
+ const serviceAccount = JSON.parse(serviceAccountJson)
51
+ log.debug({ operation: 'firebase.init', service_account_keys: Object.keys(serviceAccount ?? {}) }, 'Parsed service account keys')
52
+
53
+ if (serviceAccount == null || typeof serviceAccount !== 'object') {
54
+ throw new Error('Parsed service account is not a valid object')
55
+ }
56
+
57
+ if (serviceAccount.private_key == null || serviceAccount.client_email == null || serviceAccount.project_id == null) {
58
+ throw new Error('Service account missing required fields (private_key, client_email, project_id)')
59
+ }
60
+
61
+ firebaseCredential = cert(serviceAccount)
62
+ log.info({ operation: 'firebase.init' }, 'Firebase credential created successfully')
63
+ } catch (parseError) {
64
+ log.error({ operation: 'firebase.init', outcome: 'error', err: parseError }, 'Firebase service account parsing failed')
65
+ throw new Error(`Failed to parse FIREBASE_SERVICE_ACCOUNT_JSON: ${(parseError instanceof Error) ? parseError.message : 'Invalid JSON'}`)
66
+ }
67
+ } else if (serviceAccountPath != null && serviceAccountPath !== '') {
68
+ log.info({ operation: 'firebase.init', credential_source: 'file' }, 'Using Firebase service account key file')
69
+ const absolutePath = path.resolve(process.cwd(), serviceAccountPath)
70
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
71
+ firebaseCredential = cert(require(absolutePath))
72
+ } else {
73
+ log.info({ operation: 'firebase.init', credential_source: 'default' }, 'Using Firebase default credentials')
74
+ firebaseCredential = applicationDefault()
75
+ }
76
+
77
+ // Check if Firebase app is already initialized
78
+ if (getApps().length === 0) {
79
+ firebaseApp = initializeApp({
80
+ credential: firebaseCredential,
81
+ projectId
82
+ })
83
+ } else {
84
+ firebaseApp = getApp()
85
+ }
86
+
87
+ log.info({ operation: 'firebase.init' }, 'Firebase Admin SDK initialized successfully')
88
+ return firebaseApp
89
+ } catch (error) {
90
+ log.error({ operation: 'firebase.init', outcome: 'error', err: error }, 'Firebase initialization failed')
91
+ throw error
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get Firebase Messaging instance, or null if Firebase is not initialized.
97
+ */
98
+ export function getFirebaseMessaging(): Messaging | null {
99
+ if (firebaseApp == null) {
100
+ return null
101
+ }
102
+ return getMessaging(firebaseApp)
103
+ }
104
+
105
+ /**
106
+ * Get Firestore instance
107
+ */
108
+ export function getFirebaseFirestore(): Firestore {
109
+ if (firebaseApp == null) {
110
+ throw new Error(
111
+ 'Firebase not initialized. Call initializeFirebase() first.'
112
+ )
113
+ }
114
+ return getFirestore(firebaseApp)
115
+ }
116
+
117
+ interface FCMPayload {
118
+ title: string;
119
+ body: string;
120
+ icon?: string;
121
+ badge?: number;
122
+ data?: Record<string, string>;
123
+ }
124
+
125
+ interface SendNotificationResult {
126
+ success: boolean;
127
+ messageId: string;
128
+ }
129
+
130
+ /**
131
+ * Send a push notification via FCM
132
+ */
133
+ export async function sendNotification(
134
+ fcmToken: string,
135
+ payload: FCMPayload,
136
+ ): Promise<SendNotificationResult> {
137
+ try {
138
+ const messaging = getFirebaseMessaging()
139
+
140
+ if (messaging == null) {
141
+ throw new Error('Firebase Messaging is not initialized (ENABLE_FIREBASE != true)')
142
+ }
143
+
144
+ const message: Message = {
145
+ token: fcmToken,
146
+ notification: {
147
+ title: payload.title,
148
+ body: payload.body,
149
+ ...(payload.icon && { imageUrl: payload.icon }),
150
+ },
151
+ data: payload.data || {},
152
+ android: {
153
+ priority: "high",
154
+ notification: {
155
+ clickAction: "OPEN_ACTIVITY_1",
156
+ ...(payload.badge && { notificationCount: payload.badge }),
157
+ },
158
+ },
159
+ apns: {
160
+ headers: {
161
+ "apns-priority": "10",
162
+ },
163
+ payload: {
164
+ aps: {
165
+ alert: {
166
+ title: payload.title,
167
+ body: payload.body,
168
+ },
169
+ sound: "default",
170
+ badge: payload.badge || 1,
171
+ },
172
+ },
173
+ },
174
+ };
175
+
176
+ const response = await messaging.send(message);
177
+ log.info({ operation: 'firebase.send_notification', message_id: response }, 'Notification sent successfully')
178
+ return { success: true, messageId: response };
179
+ } catch (error) {
180
+ log.error({ operation: 'firebase.send_notification', outcome: 'error', err: error }, 'Failed to send notification')
181
+ throw error;
182
+ }
183
+ }