@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.
- package/.dockerignore +11 -0
- package/.env.example +11 -0
- package/.eslintignore +2 -0
- package/.gcloudignore +17 -0
- package/.gitattributes +2 -0
- package/.github/workflows/build-and-push.yml +51 -0
- package/.github/workflows/deploy.yaml.example +64 -0
- package/.github/workflows/image.yaml +66 -0
- package/.vscode/launch.json +67 -0
- package/AGENTS.md +92 -0
- package/BASELINE.md +55 -0
- package/DEPLOYING.md +120 -0
- package/Dockerfile +63 -0
- package/docker-compose.yml +64 -0
- package/jest.config.integration.ts +26 -0
- package/jest.config.mjs +42 -0
- package/jest.config.ts +35 -0
- package/jest.setup.mjs +18 -0
- package/nginx.conf +36 -0
- package/package.json +68 -52
- package/scripts/build.js +19 -0
- package/scripts/mkenv.sh +31 -0
- package/scripts/start.sh +16 -0
- package/src/__tests/compose.test.ts +30 -0
- package/src/app.ts +210 -0
- package/src/compose.ts +286 -0
- package/src/config/firebase.ts +183 -0
- package/src/context.ts +49 -0
- package/src/index.ts +375 -0
- package/src/routes/__tests/acknowledgeMessage.test.ts +158 -0
- package/src/routes/__tests/listMessages.test.ts +240 -0
- package/src/routes/__tests/sendMessage.test.ts +362 -0
- package/src/routes/acknowledgeMessage.ts +153 -0
- package/src/routes/index.ts +17 -0
- package/src/routes/listDevices.ts +80 -0
- package/src/routes/listMessages.ts +195 -0
- package/src/routes/permissions/getPermission.ts +152 -0
- package/src/routes/permissions/getQuote.ts +192 -0
- package/src/routes/permissions/index.ts +12 -0
- package/src/routes/permissions/listPermissions.ts +202 -0
- package/src/routes/permissions/setPermission.ts +155 -0
- package/src/routes/registerDevice.ts +155 -0
- package/src/routes/sendMessage.ts +531 -0
- package/src/runtimeDeps.ts +29 -0
- package/src/swagger.ts +54 -0
- package/src/telemetry.ts +158 -0
- package/src/types/messagePermissions.ts +18 -0
- package/src/types/mock-knex.d.ts +18 -0
- package/src/types/notifications.ts +27 -0
- package/src/utils/logger.ts +92 -0
- package/src/utils/messagePermissions.ts +148 -0
- package/src/utils/sendFCMNotification.ts +143 -0
- package/tsconfig.json +36 -0
- package/tsconfig.types.json +11 -0
- package/out/knexfile.js +0 -25
- package/out/knexfile.js.map +0 -1
- package/out/src/app.js +0 -160
- package/out/src/app.js.map +0 -1
- package/out/src/config/firebase.js +0 -150
- package/out/src/config/firebase.js.map +0 -1
- package/out/src/index.js +0 -295
- package/out/src/index.js.map +0 -1
- package/out/src/migrations/2022-12-28-001-initial-migration.js +0 -28
- package/out/src/migrations/2022-12-28-001-initial-migration.js.map +0 -1
- package/out/src/migrations/2023-01-17-messages-update.js +0 -11
- package/out/src/migrations/2023-01-17-messages-update.js.map +0 -1
- package/out/src/migrations/2024-03-05-001-messageID-upgrade.js +0 -21
- package/out/src/migrations/2024-03-05-001-messageID-upgrade.js.map +0 -1
- package/out/src/migrations/2025-01-31-001-notification-permissions.js +0 -59
- package/out/src/migrations/2025-01-31-001-notification-permissions.js.map +0 -1
- package/out/src/migrations/2025-01-31-002-device-registrations.js +0 -24
- package/out/src/migrations/2025-01-31-002-device-registrations.js.map +0 -1
- package/out/src/routes/__tests/acknowledgeMessage.test.js +0 -129
- package/out/src/routes/__tests/acknowledgeMessage.test.js.map +0 -1
- package/out/src/routes/__tests/listMessages.test.js +0 -211
- package/out/src/routes/__tests/listMessages.test.js.map +0 -1
- package/out/src/routes/__tests/sendMessage.test.js +0 -307
- package/out/src/routes/__tests/sendMessage.test.js.map +0 -1
- package/out/src/routes/acknowledgeMessage.js +0 -144
- package/out/src/routes/acknowledgeMessage.js.map +0 -1
- package/out/src/routes/index.js +0 -17
- package/out/src/routes/index.js.map +0 -1
- package/out/src/routes/listDevices.js +0 -74
- package/out/src/routes/listDevices.js.map +0 -1
- package/out/src/routes/listMessages.js +0 -186
- package/out/src/routes/listMessages.js.map +0 -1
- package/out/src/routes/permissions/getPermission.js +0 -149
- package/out/src/routes/permissions/getPermission.js.map +0 -1
- package/out/src/routes/permissions/getQuote.js +0 -165
- package/out/src/routes/permissions/getQuote.js.map +0 -1
- package/out/src/routes/permissions/index.js +0 -12
- package/out/src/routes/permissions/index.js.map +0 -1
- package/out/src/routes/permissions/listPermissions.js +0 -190
- package/out/src/routes/permissions/listPermissions.js.map +0 -1
- package/out/src/routes/permissions/setPermission.js +0 -137
- package/out/src/routes/permissions/setPermission.js.map +0 -1
- package/out/src/routes/registerDevice.js +0 -150
- package/out/src/routes/registerDevice.js.map +0 -1
- package/out/src/routes/sendMessage.js +0 -451
- package/out/src/routes/sendMessage.js.map +0 -1
- package/out/src/swagger.js +0 -49
- package/out/src/swagger.js.map +0 -1
- package/out/src/types/messagePermissions.js +0 -2
- package/out/src/types/messagePermissions.js.map +0 -1
- package/out/src/types/notifications.js +0 -2
- package/out/src/types/notifications.js.map +0 -1
- package/out/src/utils/logger.js +0 -24
- package/out/src/utils/logger.js.map +0 -1
- package/out/src/utils/messagePermissions.js +0 -126
- package/out/src/utils/messagePermissions.js.map +0 -1
- package/out/src/utils/sendFCMNotification.js +0 -121
- package/out/src/utils/sendFCMNotification.js.map +0 -1
- 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
|
+
}
|