// Push notifications — Web Push (browsers/PWA) + native FCM (Android) / APNs (iOS). // Fully optional and additive: each transport is independently config-gated, and every // call is a silent best-effort no-op when its credentials aren't set. The app is unaffected // if none are configured. // // Web Push: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (npx web-push generate-vapid-keys) // Android/FCM: FCM_SERVICE_ACCOUNT = path to (or inline JSON of) a Firebase service account // iOS/APNs: APNS_KEY (path/inline .p8), APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, // APNS_PRODUCTION=1 (use api.push.apple.com instead of the sandbox) const crypto = require('crypto'); const fs = require('fs'); const http2 = require('http2'); const R = require('./repos'); const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const strData = (o) => { const d = {}; for (const k of Object.keys(o || {})) d[k] = typeof o[k] === 'string' ? o[k] : JSON.stringify(o[k]); return d; }; // ---------------- Web Push (VAPID) ---------------- let webpush = null; try { webpush = require('web-push'); } catch (_) { /* package not installed -> web push disabled */ } const PUBLIC = process.env.VAPID_PUBLIC_KEY || ''; const PRIVATE = process.env.VAPID_PRIVATE_KEY || ''; const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com'; let webReady = false; if (webpush && PUBLIC && PRIVATE) { try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); webReady = true; } catch (e) { console.warn('[push] invalid VAPID config:', e.message); } } // ---------------- FCM (Android), HTTP v1 ---------------- let fcmSA = null; // { client_email, private_key, project_id } (function loadFcm() { const raw = process.env.FCM_SERVICE_ACCOUNT; if (!raw) return; try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); } catch (e) { console.warn('[push] FCM service account unreadable:', e.message); } })(); let fcmTok = { value: '', exp: 0 }; async function fcmAccessToken() { if (fcmTok.value && Date.now() < fcmTok.exp - 60000) return fcmTok.value; const now = Math.floor(Date.now() / 1000); const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); const claim = b64url(JSON.stringify({ iss: fcmSA.client_email, scope: 'https://www.googleapis.com/auth/firebase.messaging', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 })); const sig = b64url(crypto.sign('RSA-SHA256', Buffer.from(head + '.' + claim), fcmSA.private_key)); const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: head + '.' + claim + '.' + sig }), }); const j = await res.json(); if (!j.access_token) throw new Error('FCM token exchange failed'); fcmTok = { value: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 }; return fcmTok.value; } async function sendFcm(token, payload) { const at = await fcmAccessToken(); const msg = { message: { token, notification: { title: payload.title || 'Biz Connect', body: payload.body || '' }, data: strData(payload.data || {}) } }; const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', { method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg), }); return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens } // ---------------- APNs (iOS), token-based over HTTP/2 ---------------- let apnsKey = null, apnsCfg = null; (function loadApns() { const raw = process.env.APNS_KEY; if (!raw || !process.env.APNS_KEY_ID || !process.env.APNS_TEAM_ID || !process.env.APNS_BUNDLE_ID) return; try { apnsKey = raw.trim().startsWith('-----') ? raw : fs.readFileSync(raw, 'utf8'); apnsCfg = { keyId: process.env.APNS_KEY_ID, teamId: process.env.APNS_TEAM_ID, bundle: process.env.APNS_BUNDLE_ID, host: process.env.APNS_PRODUCTION === '1' ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com' }; } catch (e) { console.warn('[push] APNs key unreadable:', e.message); } })(); let apnsJwt = { value: '', iat: 0 }; function apnsToken() { const now = Math.floor(Date.now() / 1000); if (apnsJwt.value && now - apnsJwt.iat < 3000) return apnsJwt.value; // refresh < 50 min const head = b64url(JSON.stringify({ alg: 'ES256', kid: apnsCfg.keyId })); const claim = b64url(JSON.stringify({ iss: apnsCfg.teamId, iat: now })); const sig = b64url(crypto.sign('SHA256', Buffer.from(head + '.' + claim), { key: apnsKey, dsaEncoding: 'ieee-p1363' })); apnsJwt = { value: head + '.' + claim + '.' + sig, iat: now }; return apnsJwt.value; } // One short-lived HTTP/2 connection per send — simple and fine at low volume; pool for scale. function sendApns(token, payload) { return new Promise((resolve) => { let client; try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); } client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); }); const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: 'default' }, ...(payload.data || {}) }); const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' }); let status = 0; req.on('response', (h) => { status = h[':status']; }); req.on('data', () => {}); req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); }); req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); }); req.end(body); }); } // ---------------- public API ---------------- const nativeReady = !!(fcmSA || apnsCfg); const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean); console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] disabled (no VAPID / FCM / APNs configured)'); function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid) function publicKey() { return webReady ? PUBLIC : ''; } // Fire-and-forget to every channel the user has: Web Push subscriptions + native device // tokens. Dead endpoints/tokens are pruned. Never throws. async function sendToUser(userId, payload) { if (!webReady && !nativeReady) return; const data = JSON.stringify(payload || {}); if (webReady) { let subs = []; try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; } for (const s of subs) { try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); } catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } } } } if (nativeReady) { let toks = []; try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; } for (const t of toks) { try { let r = null; if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload); else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload); if (r && r.dead) { try { R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) { /* best-effort */ } } } } module.exports = { isEnabled, publicKey, sendToUser };