From 4c75db202913e4a6519cb34d5bf5c535d97e2a23 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 30 Jun 2026 18:23:10 +0530 Subject: [PATCH] feat(push): native device-token registration + FCM/APNs senders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/v1/devices (register) + /api/v1/devices/remove — auth-required, validates platform (ios|android), upserts by token; e2e covers register/validation/auth/remove. - db device_tokens table + deviceTokens repo. - push.js: FCM HTTP v1 (Android) and APNs token-based over HTTP/2 (iOS) folded into the single push.sendToUser path alongside Web Push; each transport independently config-gated and a silent no-op without creds. Dead tokens pruned on 404/410. - docs: CLIENTS.md Phase B updated; DEPLOY.md env table adds FCM/APNs vars. e2e 117/117. Co-Authored-By: Claude Opus 4.8 --- CLIENTS.md | 12 +++- DEPLOY.md | 4 +- server/db.js | 15 +++++ server/push.js | 144 ++++++++++++++++++++++++++++++++++++--------- server/repos.js | 11 +++- server/routes.js | 19 ++++++ server/test/e2e.js | 10 ++++ 7 files changed, 183 insertions(+), 32 deletions(-) diff --git a/CLIENTS.md b/CLIENTS.md index 2e99d26..db389c6 100644 --- a/CLIENTS.md +++ b/CLIENTS.md @@ -53,9 +53,15 @@ absolute API base if offline-launch or store policy requires it. (e.g. hide the PWA "install" prompt, enable native push instead of Web Push). ### Phase B — Native capabilities -- [ ] **Push:** server device-token registration (`/api/v1/devices`) + a pluggable - `push` sender (FCM/APNs), config-gated. Mobile uses the Capacitor push plugin; - desktop keeps Web Push / in-app. *Needs Apple/Google creds to test end-to-end.* +- [x] **Push backend:** device-token registration (`POST /api/v1/devices`, + `/api/v1/devices/remove`) + native senders folded into the single `push.sendToUser` + path — **FCM v1** (Android) and **APNs** token-based over HTTP/2 (iOS), each + config-gated and a no-op until creds are set. Web Push (VAPID) unchanged. Dead + tokens are pruned on 404/410/UNREGISTERED. (db `device_tokens`, repo `deviceTokens`, + `push.js`.) *Mobile app still needs the Capacitor push plugin wired + FCM/APNs creds + to deliver end-to-end.* +- [ ] **Wire the Capacitor push plugin** in the mobile app → register the token via + `POST /api/v1/devices` on launch; handle taps (deep link to the chat/session). - [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin). - [ ] **Deep links / universal links** so a session/meeting link opens the app. diff --git a/DEPLOY.md b/DEPLOY.md index 841f758..576a223 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -51,7 +51,9 @@ secrets already documented: | Group | Vars | Needed for | |-------|------|-----------| | Login / SSO | `BIZGAZE_LOGIN_URL`, `BIZGAZE_DIRECTORY_URL`, `BIZGAZE_DIRECTORY_TOKEN`, `SSO_SECRET` | BizGaze sign-in + directory search | -| Web Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | Background push notifications (incl. iOS PWA) | +| Web Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | Background push for browsers / installed PWA | +| Native push — Android | `FCM_SERVICE_ACCOUNT` (path to / inline Firebase service-account JSON) | FCM push to the Android app | +| Native push — iOS | `APNS_KEY` (path/inline `.p8`), `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, `APNS_PRODUCTION=1` | APNs push to the iOS app | | Calls | `TURN_URLS` / `TURN_SECRET` (or `TURN_USERNAME`+`TURN_CREDENTIAL`) | Audio/video across NATs & mobile networks | If the VAPID keys are missing, push silently no-ops (the app still runs). Push on diff --git a/server/db.js b/server/db.js index e658ce3..c44999e 100644 --- a/server/db.js +++ b/server/db.js @@ -306,6 +306,21 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id); `); +// Native device push tokens (FCM for Android, APNs for iOS) registered by the mobile app. +// Distinct from push_subscriptions (Web Push): a native token is just an opaque string + platform. +db.exec(` +CREATE TABLE IF NOT EXISTS device_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + tenant_id TEXT, + platform TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER +); +CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id); +`); + // Favourite conversations (per user). target = 'dm:' or 'group:'. db.exec(` CREATE TABLE IF NOT EXISTS favorites ( diff --git a/server/push.js b/server/push.js index f1fcaab..a27bf5d 100644 --- a/server/push.js +++ b/server/push.js @@ -1,43 +1,133 @@ -// Web Push (background / closed-tab / mobile notifications). Fully optional: -// - if the `web-push` package isn't installed, or VAPID env keys aren't set, -// isEnabled() is false and every call is a silent no-op (the app is unaffected). -// Configure in production by setting: -// VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@bizgaze.com) -// Generate a key pair once with: npx web-push generate-vapid-keys +// 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'); -let webpush = null; -try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ } +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 ready = false; +let webReady = false; if (webpush && PUBLIC && PRIVATE) { - try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); ready = true; } + try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); webReady = true; } catch (e) { console.warn('[push] invalid VAPID config:', e.message); } } -if (!ready) console.log('[push] Web Push disabled (set web-push + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY to enable).'); -function isEnabled() { return ready; } -function publicKey() { return ready ? PUBLIC : ''; } +// ---------------- 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 +} -// Fire-and-forget push to every device the user has subscribed. Dead subscriptions -// (410 Gone / 404) are pruned. Never throws. +// ---------------- 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 (!ready) return; - let subs = []; - try { subs = R.pushSubs.byUser(userId); } catch (_) { return; } + if (!webReady && !nativeReady) return; const data = JSON.stringify(payload || {}); - for (const s of subs) { - const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }; - try { - await webpush.sendNotification(sub, data, { TTL: 600 }); - } catch (err) { - const code = err && err.statusCode; - if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } - // other errors (network, 4xx) are ignored — push is best-effort + 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 */ } } } } diff --git a/server/repos.js b/server/repos.js index 3354e4e..b3ce4c0 100644 --- a/server/repos.js +++ b/server/repos.js @@ -290,4 +290,13 @@ const favorites = { forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target), }; -module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites }; +const deviceTokens = { + // Upsert by token: re-registering the same device refreshes its owner/platform/last_seen. + register: ({ id, userId, tenantId, platform, token }) => + db.prepare('INSERT INTO device_tokens (id,user_id,tenant_id,platform,token,created_at,last_seen) VALUES (?,?,?,?,?,?,?) ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, tenant_id=excluded.tenant_id, platform=excluded.platform, last_seen=excluded.last_seen') + .run(id, userId, tenantId || null, platform, token, now(), now()), + byUser: (userId) => db.prepare('SELECT * FROM device_tokens WHERE user_id=?').all(userId), + removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token), +}; + +module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens }; diff --git a/server/routes.js b/server/routes.js index c0f7610..0ee73c0 100644 --- a/server/routes.js +++ b/server/routes.js @@ -297,6 +297,25 @@ route('POST', '/api/push/unsubscribe', async (req, res) => { json(res, 200, { ok: true }); }); +// --- Native device tokens (mobile app): FCM (Android) / APNs (iOS). Registration is always +// accepted and stored; delivery is a no-op until FCM/APNs creds are configured. --- +route('POST', '/api/devices', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { platform, token } = await readBody(req); + if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' }); + if (platform !== 'ios' && platform !== 'android') return json(res, 400, { error: 'platform must be ios or android' }); + try { R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {} + json(res, 200, { ok: true }); +}); +route('POST', '/api/devices/remove', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { token } = await readBody(req); + if (token) { try { R.deviceTokens.removeByToken(token); } catch (_) {} } + json(res, 200, { ok: true }); +}); + // ---------- BizGaze SSO: agent arrives already logged in ---------- route('GET', '/sso', async (req, res) => { if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); } diff --git a/server/test/e2e.js b/server/test/e2e.js index 650b7c5..e835af8 100644 --- a/server/test/e2e.js +++ b/server/test/e2e.js @@ -95,6 +95,16 @@ function nextMsg(ws, type, timeout = 3000) { const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); check('rotated (old) refresh token is rejected', reuse.status === 401); + // 3b'. Native device push tokens (FCM/APNs registration; delivery no-ops until creds set) + const devReg = await call('/api/v1/devices', { platform: 'android', token: 'fcm-tok-123' }, cookie); + check('device token registered', devReg.status === 200 && devReg.data.ok === true); + const devBad = await call('/api/v1/devices', { platform: 'windows', token: 'x' }, cookie); + check('device register rejects invalid platform', devBad.status === 400); + const devNoAuth = await call('/api/v1/devices', { platform: 'ios', token: 'y' }); + check('device register requires auth', devNoAuth.status === 401); + const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie); + check('device token removed', devRm.status === 200); + // 3c. API keys (machine-to-machine integration), scoped + revocable const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie); check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));