Skip to content

Commit 65c43b8

Browse files
NateIsernclaude
andcommitted
feat(notifications): wire dispatcher + config, integration test
Add the notification config gate (isNotificationsEnabled/buildDispatcher) that builds FCM/APNS senders from env and prunes dead tokens, returning null when no creds are set. Wire the dispatcher into the blockchain monitor and eagerly start the monitor at boot when notifications are enabled. Document the env in .env.example. Integration test: register via route -> match a block -> assert exactly one silent push carrying only { txid, event, subscriptionId }. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f04fcf2 commit 65c43b8

5 files changed

Lines changed: 214 additions & 0 deletions

File tree

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,24 @@ PORT=8080
4343
# blank to use anonymous requests (lower rate limit).
4444
GITHUB_TOKEN=
4545

46+
# Background payment notifications (optional, Phase 1 = safe no-op if unset).
47+
# The service watches wallets' registered account xpubs and sends SILENT pushes
48+
# (only { txid, event, subscriptionId } — never amounts or addresses). Leave ALL
49+
# of these blank and the feature is completely inert; set a transport's creds to
50+
# enable it. Secrets never belong in the repo — set these in the host env only.
51+
#
52+
# Android (FCM HTTP v1): the Firebase service-account JSON, as a single-line
53+
# string (or the JSON contents). Presence of this alone enables Android pushes.
54+
FCM_SERVICE_ACCOUNT_JSON=
55+
# iOS (APNS token auth): all four are required together to enable iOS pushes.
56+
# APNS_KEY_P8 is the AuthKey .p8 contents (literal \n escapes are accepted).
57+
APNS_KEY_P8=
58+
APNS_KEY_ID=
59+
APNS_TEAM_ID=
60+
APNS_BUNDLE_ID=
61+
# APNS host: "sandbox" for dev/TestFlight-debug builds; anything else = production.
62+
APNS_ENV=production
63+
4664
# Logging
4765
# error | warn | info (default) | debug.
4866
# At info, per-connection / per-broadcast / per-poll chatter is suppressed.

server/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import mcpInfoRouter from './routes/mcp-info'
2727
import transactionsRouter from './routes/transactions'
2828
import notificationsRouter from './routes/notifications'
2929
import { createMcpPostHandler, handleMcpMethodNotAllowed, handleMcpOptions } from './mcp/http'
30+
import { isNotificationsEnabled } from './lib/notifications/config'
3031
import packageJson from '../package.json' with { type: 'json' }
3132

3233
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -800,6 +801,18 @@ wss.on('connection', async (ws, request) => {
800801
server.listen(PORT, '0.0.0.0', () => {
801802
console.log(`> API server ready on http://0.0.0.0:${PORT}`)
802803
console.log(`> WebSocket server ready on ws://0.0.0.0:${PORT}/api/ws`)
804+
805+
// The blockchain monitor (which drives payment notifications) lives in the
806+
// WebSocket handler module, normally loaded lazily on the first WS client. When
807+
// notifications are configured, load it now so pushes fire regardless of
808+
// whether any browser ever opens a WebSocket.
809+
if (isNotificationsEnabled()) {
810+
void loadWsHandler().then((handler) => {
811+
if (handler) {
812+
console.log('> Background payment notifications enabled')
813+
}
814+
})
815+
}
803816
})
804817

805818
process.on('SIGTERM', () => { console.log('SIGTERM: closing'); process.exit(0) })

server/lib/notifications/config.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Notification feature configuration — the single gate that makes the whole
2+
// feature Phase-1-safe. With no FCM/APNS credentials in the environment,
3+
// `isNotificationsEnabled` is false and `buildDispatcher` returns null, so the
4+
// monitor is never given a dispatcher and the entire notification path is an
5+
// inert no-op. Nothing here throws on missing/bad creds: a misconfigured
6+
// provider is logged and simply left disabled.
7+
8+
import { logger } from '../logger'
9+
import { createDispatcher, type PushDispatcher } from '../push'
10+
import { createFcmSender } from '../push/fcm'
11+
import { createApnsSender } from '../push/apns'
12+
import type { ApnsSender, FcmSender } from '../push/types'
13+
import { deleteSubscription } from './subscriptions'
14+
15+
type Env = Record<string, string | undefined>
16+
17+
/**
18+
* Whether background notifications are configured. True as soon as EITHER
19+
* transport's credentials are present; each transport is wired independently.
20+
*/
21+
export function isNotificationsEnabled(env: Env = process.env): boolean {
22+
return Boolean(env.FCM_SERVICE_ACCOUNT_JSON || isApnsConfigured(env))
23+
}
24+
25+
function isApnsConfigured(env: Env): boolean {
26+
return Boolean(env.APNS_KEY_P8 && env.APNS_KEY_ID && env.APNS_TEAM_ID && env.APNS_BUNDLE_ID)
27+
}
28+
29+
function buildFcmSender(env: Env): FcmSender | undefined {
30+
if (!env.FCM_SERVICE_ACCOUNT_JSON) {
31+
return undefined
32+
}
33+
try {
34+
return createFcmSender(env.FCM_SERVICE_ACCOUNT_JSON)
35+
} catch (error) {
36+
logger.error('[notifications] FCM disabled: invalid FCM_SERVICE_ACCOUNT_JSON', error)
37+
return undefined
38+
}
39+
}
40+
41+
function buildApnsSender(env: Env): ApnsSender | undefined {
42+
if (!isApnsConfigured(env) || !env.APNS_KEY_P8 || !env.APNS_KEY_ID || !env.APNS_TEAM_ID || !env.APNS_BUNDLE_ID) {
43+
return undefined
44+
}
45+
try {
46+
return createApnsSender({
47+
// Env-stored PEMs commonly carry escaped newlines; restore them.
48+
p8: env.APNS_KEY_P8.replace(/\\n/g, '\n'),
49+
keyId: env.APNS_KEY_ID,
50+
teamId: env.APNS_TEAM_ID,
51+
bundleId: env.APNS_BUNDLE_ID,
52+
production: env.APNS_ENV !== 'sandbox',
53+
})
54+
} catch (error) {
55+
logger.error('[notifications] APNS disabled: invalid APNS_* configuration', error)
56+
return undefined
57+
}
58+
}
59+
60+
/**
61+
* Build the push dispatcher from the environment, or null when neither transport
62+
* is configured. Dead tokens prune the subscription (and its watched addresses).
63+
*/
64+
export function buildDispatcher(env: Env = process.env): PushDispatcher | null {
65+
const fcm = buildFcmSender(env)
66+
const apns = buildApnsSender(env)
67+
if (!fcm && !apns) {
68+
return null
69+
}
70+
return createDispatcher({
71+
fcm,
72+
apns,
73+
onDeadToken: (subscriptionId) => deleteSubscription(subscriptionId),
74+
})
75+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
2+
import express from 'express'
3+
import type { Server } from 'node:http'
4+
import { AddressInfo } from 'node:net'
5+
import notificationsRouter from '../../routes/notifications'
6+
import { setupMemoryMongo, teardownMemoryMongo, clearCollections } from './test-support'
7+
import { createDispatcher } from '../push'
8+
import { matchBlock, type ParsedBlock } from './matcher'
9+
import { isNotificationsEnabled, buildDispatcher } from './config'
10+
import type { PushData, PushSendResult } from '../push/types'
11+
12+
const XPUB =
13+
'ToEA6m5JcxaE72JyzLAvHoonWB3GgAxzt5jADQC9Pc43q4V3omj94DdKe98KkRgY2nqwbNHxWCceM3o62VhFVe7HCnAW7yBGEvfbfwrYsK3N7t7'
14+
const RECEIVE_0 = 'FQVANvQqVsLwkwBnAJ5oPDYrqcfXLak7Bf'
15+
const DEVICE_TOKEN = 'device-token-abc'
16+
const TXID = 'a'.repeat(64)
17+
18+
let server: Server
19+
let baseUrl: string
20+
21+
describe('notifications end-to-end', () => {
22+
beforeAll(async () => {
23+
await setupMemoryMongo()
24+
const app = express()
25+
app.use(express.json())
26+
app.use('/api/notifications', notificationsRouter)
27+
server = app.listen(0)
28+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
29+
}, 60000)
30+
31+
afterAll(async () => {
32+
await new Promise<void>((resolve) => server.close(() => resolve()))
33+
await teardownMemoryMongo()
34+
})
35+
36+
beforeEach(async () => {
37+
await clearCollections()
38+
})
39+
40+
it('registers via the route, matches a block, and dispatches exactly one content-free push', async () => {
41+
// 1. Register the wallet's watch-only xpub through the real route.
42+
const res = await fetch(`${baseUrl}/api/notifications/register`, {
43+
method: 'POST',
44+
headers: { 'content-type': 'application/json' },
45+
body: JSON.stringify({
46+
xpub: XPUB,
47+
scriptType: 'p2pkh',
48+
gapLimit: 5,
49+
network: 'mainnet',
50+
deviceToken: DEVICE_TOKEN,
51+
platform: 'android',
52+
confirmations: 1,
53+
events: ['incoming_pending', 'incoming_confirmed', 'outgoing_confirmed'],
54+
}),
55+
})
56+
expect(res.status).toBe(200)
57+
const { subscriptionId } = (await res.json()) as { subscriptionId: string }
58+
59+
// 2. A real dispatcher over a mock FCM sender that captures what is sent.
60+
const sent: Array<{ token: string; data: PushData }> = []
61+
const dispatch = createDispatcher({
62+
fcm: async (token, data): Promise<PushSendResult> => {
63+
sent.push({ token, data })
64+
return { deadToken: false }
65+
},
66+
})
67+
68+
// 3. Feed a synthetic block paying the registered index-0 receive address.
69+
const block: ParsedBlock = {
70+
network: 'mainnet',
71+
depth: 0,
72+
txs: [{ txid: TXID, outputAddresses: [RECEIVE_0], inputAddresses: [] }],
73+
}
74+
await matchBlock(block, { dispatch })
75+
76+
// 4. Exactly one push, to the registered device, carrying ONLY the wake signal.
77+
expect(sent).toHaveLength(1)
78+
expect(sent[0].token).toBe(DEVICE_TOKEN)
79+
expect(Object.keys(sent[0].data).sort()).toEqual(['event', 'subscriptionId', 'txid'])
80+
expect(sent[0].data).toEqual({ txid: TXID, event: 'incoming_pending', subscriptionId })
81+
// No amount/address/balance ever transits the provider.
82+
expect(JSON.stringify(sent[0].data).toLowerCase()).not.toMatch(/amount|address|balance|value|fair/)
83+
})
84+
85+
it('is disabled and dispatcher-less when no provider credentials are present', () => {
86+
expect(isNotificationsEnabled({})).toBe(false)
87+
expect(buildDispatcher({})).toBeNull()
88+
})
89+
90+
it('enables and builds a dispatcher from a structurally valid FCM service account', () => {
91+
const serviceAccount = JSON.stringify({
92+
client_email: 'notifier@example.iam.gserviceaccount.com',
93+
private_key: 'unused-at-construction',
94+
project_id: 'fair-explorer',
95+
})
96+
expect(isNotificationsEnabled({ FCM_SERVICE_ACCOUNT_JSON: serviceAccount })).toBe(true)
97+
expect(typeof buildDispatcher({ FCM_SERVICE_ACCOUNT_JSON: serviceAccount })).toBe('function')
98+
})
99+
})

server/lib/websocket-handler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { WebSocket } from 'ws'
44
import { getWebSocketManager, shutdownWebSocketManager } from './websocket-manager'
55
import { getBlockchainMonitor, shutdownBlockchainMonitor } from './blockchain-monitor'
6+
import { buildDispatcher } from './notifications/config'
67
import {
78
NetworkType,
89
ClientMessage,
@@ -34,6 +35,14 @@ const blockchainMonitor = getBlockchainMonitor(wsManager, {
3435
enabled: process.env.WEBSOCKET_ENABLED !== 'false',
3536
})
3637

38+
// Wire background payment notifications. With no FCM/APNS credentials configured
39+
// this returns null and the monitor's notification path stays an inert no-op.
40+
const notificationDispatcher = buildDispatcher()
41+
if (notificationDispatcher) {
42+
blockchainMonitor.setNotificationDispatcher(notificationDispatcher)
43+
logger.info('[WebSocketHandler] Background payment notifications enabled')
44+
}
45+
3746
// Start blockchain monitor once at module load (this module is evaluated a
3847
// single time via the lazy import in server/index.ts).
3948
blockchainMonitor.start().then(() => {

0 commit comments

Comments
 (0)