@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,29 @@
1
+ /**
2
+ * Injectable knex/wallet for route handlers.
3
+ * Binary entry and mountMessageBoxRoutes call bindMessageBoxRuntime before serving.
4
+ */
5
+ import type { Knex } from 'knex'
6
+ import type { WalletInterface } from '@bsv/sdk'
7
+
8
+ /** Bound before serving; routes read knex/wallet from here. */
9
+ export const runtimeDeps: {
10
+ knex: Knex
11
+ wallet?: WalletInterface
12
+ } = {
13
+ knex: null as unknown as Knex
14
+ }
15
+
16
+ export function bindMessageBoxRuntime (deps: {
17
+ knex: Knex
18
+ wallet?: WalletInterface
19
+ }): void {
20
+ runtimeDeps.knex = deps.knex
21
+ runtimeDeps.wallet = deps.wallet
22
+ }
23
+
24
+ export async function getWallet (): Promise<WalletInterface> {
25
+ if (runtimeDeps.wallet == null) {
26
+ throw new Error('Wallet is not initialized')
27
+ }
28
+ return runtimeDeps.wallet
29
+ }
package/src/swagger.ts ADDED
@@ -0,0 +1,54 @@
1
+ import swaggerJsdoc from 'swagger-jsdoc'
2
+ import swaggerUi from 'swagger-ui-express'
3
+ import { Express } from 'express'
4
+
5
+ export function setupSwagger (app: Express): void {
6
+ const options = {
7
+ definition: {
8
+ openapi: '3.0.0',
9
+ info: {
10
+ title: 'MessageBox Server API',
11
+ version: '1.0.0',
12
+ description: 'API documentation for the MessageBox Server, including message delivery, retrieval, acknowledgment, and overlay routing.'
13
+ },
14
+ servers: [
15
+ {
16
+ url: 'http://localhost:5001',
17
+ description: 'Local Development Server'
18
+ },
19
+ {
20
+ url: 'https://messagebox.babbage.systems',
21
+ description: 'Production MessageBox Server'
22
+ }
23
+ ],
24
+ components: {
25
+ securitySchemes: {
26
+ BearerAuth: {
27
+ type: 'http',
28
+ scheme: 'bearer',
29
+ bearerFormat: 'JWT',
30
+ description: 'Provide your signed identity key JWT in the `Authorization` header as: `Bearer <token>`'
31
+ }
32
+ }
33
+ },
34
+ security: [
35
+ {
36
+ BearerAuth: []
37
+ }
38
+ ]
39
+ },
40
+ apis: ['./src/routes/*.ts']
41
+ }
42
+
43
+ const swaggerSpec = swaggerJsdoc(options)
44
+
45
+ // Swagger UI at /docs — cast needed for @types/swagger-ui-express v4 + @types/express v5 compat
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ ;(app as any).use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec))
48
+
49
+ // Serve raw OpenAPI spec at /openapi.json
50
+ app.get('/openapi.json', (_req, res) => {
51
+ res.setHeader('Content-Type', 'application/json')
52
+ res.send(swaggerSpec)
53
+ })
54
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * OpenTelemetry bootstrap (ESM) — preloaded before app code via
3
+ * `node --import ./out/src/telemetry.js`.
4
+ *
5
+ * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when
6
+ * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the
7
+ * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are
8
+ * enabled to support memory-leak diagnosis.
9
+ *
10
+ * ESM note: we deliberately do NOT register the import-in-the-middle loader
11
+ * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named
12
+ * exports of CJS packages imported as ESM and drops some of them (e.g.
13
+ * @bsv/sdk's PushDrop), which crashes the app at import time. The libraries we
14
+ * actually instrument (http, express, mongodb, mysql2, pino) are pulled in
15
+ * through CJS dependency chains (overlay-express, wallet-toolbox, authsocket)
16
+ * and are still patched by require-in-the-middle, so coverage is retained.
17
+ *
18
+ * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md.
19
+ */
20
+ import { createRequire } from 'node:module'
21
+ import { join } from 'node:path'
22
+
23
+ import { NodeSDK } from '@opentelemetry/sdk-node'
24
+ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
25
+ import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node'
26
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
27
+ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
28
+ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
29
+ import {
30
+ ConsoleSpanExporter,
31
+ BatchSpanProcessor,
32
+ SimpleSpanProcessor,
33
+ type SpanExporter,
34
+ } from '@opentelemetry/sdk-trace-base'
35
+ import {
36
+ PeriodicExportingMetricReader,
37
+ ConsoleMetricExporter,
38
+ type PushMetricExporter,
39
+ } from '@opentelemetry/sdk-metrics'
40
+ import {
41
+ BatchLogRecordProcessor,
42
+ SimpleLogRecordProcessor,
43
+ ConsoleLogRecordExporter,
44
+ type LogRecordExporter,
45
+ } from '@opentelemetry/sdk-logs'
46
+ import { resourceFromAttributes } from '@opentelemetry/resources'
47
+ import {
48
+ ATTR_SERVICE_NAME,
49
+ ATTR_SERVICE_VERSION,
50
+ } from '@opentelemetry/semantic-conventions'
51
+ import { logs, SeverityNumber } from '@opentelemetry/api-logs'
52
+ import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'
53
+
54
+ // Resolve the component's package.json relative to the working directory (the
55
+ // app root in every Dockerfile and local run) — robust regardless of build layout.
56
+ const require = createRequire(import.meta.url)
57
+ const pkg = require(join(process.cwd(), 'package.json')) as { name: string; version: string }
58
+
59
+ const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
60
+ const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0
61
+ const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development'
62
+
63
+ if (process.env.OTEL_DIAG === 'true') {
64
+ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO)
65
+ }
66
+
67
+ const resource = resourceFromAttributes({
68
+ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name,
69
+ [ATTR_SERVICE_VERSION]: pkg.version,
70
+ 'deployment.environment': env,
71
+ })
72
+
73
+ const traceExporter: SpanExporter = useOtlp
74
+ ? new OTLPTraceExporter()
75
+ : new ConsoleSpanExporter()
76
+ const metricExporter: PushMetricExporter = useOtlp
77
+ ? new OTLPMetricExporter()
78
+ : new ConsoleMetricExporter()
79
+ const logExporter: LogRecordExporter = useOtlp
80
+ ? new OTLPLogExporter()
81
+ : new ConsoleLogRecordExporter()
82
+
83
+ const logRecordProcessor = useOtlp
84
+ ? new BatchLogRecordProcessor(logExporter)
85
+ : new SimpleLogRecordProcessor(logExporter)
86
+
87
+ const sdk = new NodeSDK({
88
+ resource,
89
+ spanProcessors: [
90
+ useOtlp
91
+ ? new BatchSpanProcessor(traceExporter)
92
+ : new SimpleSpanProcessor(traceExporter),
93
+ ],
94
+ metricReader: new PeriodicExportingMetricReader({
95
+ exporter: metricExporter,
96
+ exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000),
97
+ }),
98
+ logRecordProcessors: [logRecordProcessor],
99
+ instrumentations: [
100
+ getNodeAutoInstrumentations({
101
+ '@opentelemetry/instrumentation-fs': { enabled: false },
102
+ }),
103
+ new RuntimeNodeInstrumentation(),
104
+ ],
105
+ })
106
+
107
+ sdk.start()
108
+
109
+ // Capture clean console refs before patching, for telemetry's own messages.
110
+ const rawInfo = console.info.bind(console)
111
+ const rawError = console.error.bind(console)
112
+
113
+ // Bridge stray console.* calls into OTel logs so nothing is lost while code is
114
+ // migrated to the structured (pino) logger.
115
+ const logger = logs.getLogger(pkg.name, pkg.version)
116
+ const SEVERITY: Record<string, SeverityNumber> = {
117
+ debug: SeverityNumber.DEBUG,
118
+ info: SeverityNumber.INFO,
119
+ log: SeverityNumber.INFO,
120
+ warn: SeverityNumber.WARN,
121
+ error: SeverityNumber.ERROR,
122
+ }
123
+ for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) {
124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
+ const original = (console as any)[method].bind(console)
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ ;(console as any)[method] = (...args: any[]) => {
128
+ try {
129
+ logger.emit({
130
+ severityNumber: SEVERITY[method],
131
+ severityText: method.toUpperCase(),
132
+ body: args
133
+ .map((a) =>
134
+ typeof a === 'string' ? a : (() => {
135
+ try { return JSON.stringify(a) } catch { return String(a) }
136
+ })()
137
+ )
138
+ .join(' '),
139
+ })
140
+ } catch {
141
+ /* never let telemetry break logging */
142
+ }
143
+ original(...args)
144
+ }
145
+ }
146
+
147
+ // Flush telemetry on shutdown. Only force-exit if the app registered no handler
148
+ // of its own for this signal — otherwise we let the app's shutdown drive exit.
149
+ const shutdown = (signal: NodeJS.Signals) => {
150
+ sdk.shutdown()
151
+ .then(() => rawInfo(`[otel] flushed (${signal})`))
152
+ .catch((err: unknown) => rawError('[otel] shutdown error', err))
153
+ .finally(() => {
154
+ if (process.listeners(signal).length <= 1) process.exit(0)
155
+ })
156
+ }
157
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
158
+ process.on('SIGINT', () => shutdown('SIGINT'))
@@ -0,0 +1,18 @@
1
+ import { PubKeyHex } from '@bsv/sdk'
2
+
3
+ // Generalized message permission for any box/sender combination
4
+ export interface MessagePermission {
5
+ id: number
6
+ recipient: PubKeyHex // identityKey of permission owner
7
+ sender: PubKeyHex | null // identityKey of sender (null for box-wide defaults)
8
+ message_box: string // messageBox type (e.g., 'notifications', 'inbox', etc.)
9
+ recipient_fee: number // -1 = block, 0 = free, >0 = satoshi amount required
10
+ created_at: Date
11
+ updated_at: Date
12
+ }
13
+
14
+ // Fee calculation result
15
+ export interface FeeCalculationResult {
16
+ deliveryFee: number
17
+ recipientFee: number
18
+ }
@@ -0,0 +1,18 @@
1
+ declare module 'mock-knex' {
2
+ import { Knex } from 'knex'
3
+
4
+ export interface Tracker {
5
+ install: () => void
6
+ uninstall: () => void
7
+ on: (event: 'query', callback: (query: any, step: number) => void) => void
8
+ }
9
+
10
+ export interface MockKnex {
11
+ mock: (db: Knex) => void
12
+ unmock: (db: Knex) => void
13
+ getTracker: () => Tracker
14
+ }
15
+
16
+ const mockKnex: MockKnex
17
+ export default mockKnex
18
+ }
@@ -0,0 +1,27 @@
1
+ import { PubKeyHex } from '@bsv/sdk'
2
+
3
+ // TODO: Determine payload structure for notifications
4
+ export interface EncryptedNotificationPayload {
5
+ title?: string // Optional unencrypted title for display
6
+ }
7
+
8
+ // Optional payment structure
9
+ export interface NotificationPayment {
10
+ amount: number // Satoshis
11
+ recipient: PubKeyHex
12
+ // TODO: Add more payment fields as needed for internalizeAction
13
+ }
14
+
15
+ // FCM configuration interface (adapted from Jackie's code)
16
+ export interface FCMPayload {
17
+ title: string
18
+ body: string
19
+ icon?: string
20
+ badge?: number
21
+ data?: Record<string, string>
22
+ }
23
+
24
+ export interface SendNotificationResult {
25
+ success: boolean
26
+ messageId: string
27
+ }
@@ -0,0 +1,92 @@
1
+ import pino from 'pino'
2
+ import { createRequire } from 'node:module'
3
+ import { join } from 'node:path'
4
+
5
+ const require = createRequire(import.meta.url)
6
+ const pkg = require(join(process.cwd(), 'package.json')) as { name: string; version: string }
7
+
8
+ // Structured pino logger. @opentelemetry/instrumentation-pino (loaded by
9
+ // telemetry.ts) injects trace_id/span_id into every record, and records are
10
+ // shipped to the OTLP logs endpoint. Stable base fields: service, env.
11
+ const pinoLogger = pino({
12
+ name: pkg.name,
13
+ level: process.env.LOG_LEVEL ?? 'info',
14
+ base: {
15
+ service: process.env.OTEL_SERVICE_NAME ?? pkg.name,
16
+ env: process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development',
17
+ },
18
+ // Scrub PII / credentials before records leave the process. on-chain data
19
+ // (identity_key, txid) is public and intentionally NOT redacted.
20
+ redact: {
21
+ paths: [
22
+ 'phone', 'phoneNumber', 'identifier',
23
+ 'presentation_key', 'presentationKey', 'payload', 'store',
24
+ 'password', 'pass', 'secret', 'privateKey', 'private_key',
25
+ 'authorization', 'token', 'access_token',
26
+ '*.phone', '*.phoneNumber', '*.identifier', '*.authorization',
27
+ ],
28
+ censor: '[redacted]',
29
+ },
30
+ formatters: {
31
+ level: (label) => ({ level: label }),
32
+ },
33
+ })
34
+
35
+ // Export the raw pino logger for call sites that want structured fields:
36
+ // log.info({ operation: 'send', room_id }, 'message delivered')
37
+ export const log = pinoLogger
38
+
39
+ // Split variadic args into a message string + structured detail object so legacy
40
+ // Logger.log('text', value) calls still produce useful structured records.
41
+ function emit(level: 'info' | 'warn' | 'error', args: unknown[]): void {
42
+ const strings: string[] = []
43
+ const details: unknown[] = []
44
+ for (const a of args) {
45
+ if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') {
46
+ strings.push(String(a))
47
+ } else {
48
+ details.push(a)
49
+ }
50
+ }
51
+ const msg = strings.join(' ')
52
+ if (details.length > 0) {
53
+ pinoLogger[level]({ detail: details.length === 1 ? details[0] : details }, msg)
54
+ } else {
55
+ pinoLogger[level](msg)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Backwards-compatible static facade over the structured logger. Existing
61
+ * Logger.log/warn/error call sites keep working but now emit structured,
62
+ * trace-correlated records. `enable`/`disable` toggle info/warn verbosity;
63
+ * errors are always emitted.
64
+ */
65
+ // eslint-disable-next-line @typescript-eslint/no-extraneous-class
66
+ export class Logger {
67
+ private static isEnabled = false
68
+
69
+ static enable(): void {
70
+ this.isEnabled = true
71
+ }
72
+
73
+ static disable(): void {
74
+ this.isEnabled = false
75
+ }
76
+
77
+ static log(...args: unknown[]): void {
78
+ if (this.isEnabled) {
79
+ emit('info', args)
80
+ }
81
+ }
82
+
83
+ static warn(...args: unknown[]): void {
84
+ if (this.isEnabled) {
85
+ emit('warn', args)
86
+ }
87
+ }
88
+
89
+ static error(...args: unknown[]): void {
90
+ emit('error', args)
91
+ }
92
+ }
@@ -0,0 +1,148 @@
1
+ import { Logger } from './logger.js'
2
+ import { PubKeyHex } from '@bsv/sdk'
3
+ import { runtimeDeps } from '../runtimeDeps.js'
4
+
5
+ /**
6
+ * Fee calculation result structure
7
+ */
8
+ export interface FeeCalculationResult {
9
+ delivery_fee: number
10
+ recipient_fee: number
11
+ total_cost: number
12
+ allowed: boolean
13
+ requires_payment: boolean
14
+ blocked_reason?: string
15
+ }
16
+
17
+ /**
18
+ * Get server delivery fee for a message box type
19
+ */
20
+ export async function getServerDeliveryFee(messageBox: string): Promise<number> {
21
+ try {
22
+ const serverFee = await runtimeDeps.knex('server_fees')
23
+ .where({ message_box: messageBox })
24
+ .select('delivery_fee')
25
+ .first()
26
+
27
+ return serverFee?.delivery_fee ?? 0
28
+ } catch (error) {
29
+ Logger.error('[ERROR] Error getting server delivery fee:', error)
30
+ return 0
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Get recipient fee for a sender/messageBox combination with hierarchical fallback
36
+ */
37
+ export async function getRecipientFee(
38
+ recipient: PubKeyHex,
39
+ sender: PubKeyHex | null,
40
+ messageBox: string
41
+ ): Promise<number> {
42
+ try {
43
+ // Debug parameter types
44
+ Logger.log(`[DEBUG] getRecipientFee params - recipient: ${typeof recipient} (${JSON.stringify(recipient)}), sender: ${typeof sender} (${JSON.stringify(sender)}), messageBox: ${typeof messageBox} (${JSON.stringify(messageBox)})`)
45
+
46
+ // First try sender-specific permission
47
+ if (sender != null) {
48
+ const senderSpecific = await runtimeDeps.knex('message_permissions')
49
+ .where({
50
+ recipient: String(recipient),
51
+ sender: String(sender),
52
+ message_box: String(messageBox)
53
+ })
54
+ .select('recipient_fee')
55
+ .first()
56
+
57
+ if (senderSpecific != null) {
58
+ return senderSpecific.recipient_fee
59
+ }
60
+ }
61
+
62
+ // Fallback to box-wide default
63
+ const boxWideDefault = await runtimeDeps.knex('message_permissions')
64
+ .where({
65
+ recipient: String(recipient),
66
+ sender: null, // Box-wide default
67
+ message_box: String(messageBox)
68
+ })
69
+ .select('recipient_fee')
70
+ .first()
71
+
72
+ if (boxWideDefault != null) {
73
+ return boxWideDefault.recipient_fee
74
+ }
75
+
76
+ // Auto-create box-wide default if none exists
77
+ const defaultFee = getSmartDefaultFee(String(messageBox))
78
+ await runtimeDeps.knex('message_permissions').insert({
79
+ recipient: String(recipient),
80
+ sender: null,
81
+ message_box: String(messageBox),
82
+ recipient_fee: defaultFee,
83
+ created_at: new Date(),
84
+ updated_at: new Date()
85
+ })
86
+
87
+ Logger.log(`[DEBUG] Created box-wide default permission for ${recipient}/${messageBox} with fee ${defaultFee}`)
88
+ return defaultFee
89
+ } catch (error) {
90
+ Logger.error('[ERROR] Error getting recipient fee:', error)
91
+ return 0 // Block on error
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get smart default fee based on message box type
97
+ */
98
+ function getSmartDefaultFee(messageBox: string): number {
99
+ // Notifications are premium service
100
+ if (messageBox === 'notifications') {
101
+ return 10 // 10 satoshis
102
+ }
103
+
104
+ // Other message boxes are always allowed by default
105
+ return 0
106
+ }
107
+
108
+ /**
109
+ * Set message permission for a sender/recipient/messageBox combination
110
+ */
111
+ export async function setMessagePermission(
112
+ recipient: PubKeyHex,
113
+ sender: PubKeyHex | null,
114
+ messageBox: string,
115
+ recipientFee: number
116
+ ): Promise<boolean> {
117
+ try {
118
+ const now = new Date()
119
+
120
+ // Use upsert (insert or update)
121
+ await runtimeDeps.knex('message_permissions')
122
+ .insert({
123
+ recipient,
124
+ sender,
125
+ message_box: messageBox,
126
+ recipient_fee: recipientFee,
127
+ created_at: now,
128
+ updated_at: now
129
+ })
130
+ .onConflict(['recipient', 'sender', 'message_box'])
131
+ .merge({
132
+ recipient_fee: recipientFee,
133
+ updated_at: now
134
+ })
135
+
136
+ return true
137
+ } catch (error) {
138
+ Logger.error('[ERROR] Error setting message permission:', error)
139
+ return false
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Check if FCM delivery should be used for this message box
145
+ */
146
+ export function shouldUseFCMDelivery(messageBox: string): boolean {
147
+ return messageBox === 'notifications'
148
+ }
@@ -0,0 +1,143 @@
1
+ import { getFirebaseMessaging } from '../config/firebase.js'
2
+ import { Logger } from './logger.js'
3
+ import { PubKeyHex } from '@bsv/sdk'
4
+ import { runtimeDeps } from '../runtimeDeps.js'
5
+
6
+ /**
7
+ * FCM Payload interface
8
+ */
9
+ export interface FCMPayload {
10
+ title: string
11
+ messageId: string
12
+ originator?: string
13
+ }
14
+
15
+ /**
16
+ * FCM notification result
17
+ */
18
+ export interface SendNotificationResult {
19
+ success: boolean
20
+ error?: string
21
+ }
22
+
23
+ /**
24
+ * Send FCM push notification to all registered devices for a recipient
25
+ * Looks up FCM tokens from device_registrations table and sends to all active devices
26
+ */
27
+ export async function sendFCMNotification(
28
+ recipient: PubKeyHex,
29
+ payload: FCMPayload
30
+ ): Promise<SendNotificationResult> {
31
+ try {
32
+ Logger.log(`[DEBUG] Attempting to send FCM notification to ${recipient}`)
33
+ Logger.log('[DEBUG] Payload:', payload)
34
+
35
+ // Look up all active FCM tokens for this recipient
36
+ const deviceRegistrations = await runtimeDeps.knex('device_registrations')
37
+ .where({
38
+ identity_key: recipient,
39
+ active: true
40
+ })
41
+ .select('fcm_token', 'platform', 'device_id')
42
+
43
+ if (deviceRegistrations.length === 0) {
44
+ Logger.log(`[DEBUG] No active FCM tokens found for recipient ${recipient}`)
45
+ return { success: false, error: 'No registered devices found for recipient' }
46
+ }
47
+
48
+ Logger.log(`[DEBUG] Found ${deviceRegistrations.length} active device(s) for ${recipient}`)
49
+
50
+ // Send notification to all registered devices
51
+ const sendPromises = deviceRegistrations.map(async (device) => {
52
+ try {
53
+ Logger.log(`[DEBUG] Sending to ${device.platform ?? 'unknown'} device: ${device.device_id ?? 'unknown'}`)
54
+
55
+ const messaging = getFirebaseMessaging()
56
+ if (messaging == null) {
57
+ return { success: false, token: device.fcm_token, error: 'Firebase Messaging not initialized (ENABLE_FIREBASE != true)' }
58
+ }
59
+
60
+ await messaging.send({
61
+ token: device.fcm_token,
62
+ notification: {
63
+ title: payload.title,
64
+ body: payload.messageId
65
+ },
66
+ // Android configuration for headless service
67
+ android: {
68
+ priority: 'high',
69
+ data: {
70
+ messageId: payload.messageId,
71
+ originator: payload.originator || 'unknown'
72
+ }
73
+ },
74
+ // iOS configuration for mutable content and Notification Service Extension
75
+ apns: {
76
+ headers: {
77
+ 'apns-push-type': 'alert', // required for iOS 13+
78
+ 'apns-priority': '10', // deliver immediately
79
+ // optional: 'apns-topic': '<your app bundle id>' // FCM fills this automatically
80
+ },
81
+ payload: {
82
+ aps: {
83
+ 'mutable-content': 1,
84
+ alert: { // include an alert so NSE can modify it
85
+ title: payload.title,
86
+ body: payload.messageId,
87
+ },
88
+ // do NOT set 'content-available': 1 unless you also want background fetch
89
+ },
90
+ // custom keys your NSE can read:
91
+ messageId: payload.messageId,
92
+ originator: payload.originator ?? 'unknown',
93
+ },
94
+ },
95
+ })
96
+
97
+ // Update last_used timestamp on successful send
98
+ await runtimeDeps.knex('device_registrations')
99
+ .where('fcm_token', device.fcm_token)
100
+ .update({
101
+ last_used: new Date(),
102
+ updated_at: new Date()
103
+ })
104
+
105
+ return { success: true, token: device.fcm_token }
106
+ } catch (error) {
107
+ Logger.error(`[FCM ERROR] Failed to send to token ${device.fcm_token.slice(-10)}:`, error)
108
+
109
+ // Mark token as inactive if it's invalid
110
+ if (error instanceof Error && (
111
+ error.message.includes('registration-token-not-registered') ||
112
+ error.message.includes('invalid-registration-token')
113
+ )) {
114
+ Logger.log(`[DEBUG] Marking invalid token as inactive: ...${device.fcm_token.slice(-10)}`)
115
+ await runtimeDeps.knex('device_registrations')
116
+ .where('fcm_token', device.fcm_token)
117
+ .update({
118
+ active: false,
119
+ updated_at: new Date()
120
+ })
121
+ }
122
+
123
+ return { success: false, token: device.fcm_token, error: error instanceof Error ? error.message : String(error) }
124
+ }
125
+ })
126
+
127
+ const results = await Promise.all(sendPromises)
128
+ const successCount = results.filter(r => r.success).length
129
+ const failureCount = results.length - successCount
130
+
131
+ Logger.log(`[DEBUG] FCM notification results: ${successCount} successful, ${failureCount} failed`)
132
+
133
+ // Consider it successful if at least one device received the notification
134
+ if (successCount > 0) {
135
+ return { success: true }
136
+ } else {
137
+ return { success: false, error: `Failed to send to all ${results.length} registered devices` }
138
+ }
139
+ } catch (error) {
140
+ Logger.error('[FCM ERROR] Failed to send FCM notification:', error)
141
+ return { success: false, error: error instanceof Error ? error.message : String(error) }
142
+ }
143
+ }