feat(push): native device-token registration + FCM/APNs senders

- /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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:23:10 +05:30
parent 593a4677b6
commit 4c75db2029
7 changed files with 183 additions and 32 deletions
+9 -3
View File
@@ -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). (e.g. hide the PWA "install" prompt, enable native push instead of Web Push).
### Phase B — Native capabilities ### Phase B — Native capabilities
- [ ] **Push:** server device-token registration (`/api/v1/devices`) + a pluggable - [x] **Push backend:** device-token registration (`POST /api/v1/devices`,
`push` sender (FCM/APNs), config-gated. Mobile uses the Capacitor push plugin; `/api/v1/devices/remove`) + native senders folded into the single `push.sendToUser`
desktop keeps Web Push / in-app. *Needs Apple/Google creds to test end-to-end.* 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). - [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin).
- [ ] **Deep links / universal links** so a session/meeting link opens the app. - [ ] **Deep links / universal links** so a session/meeting link opens the app.
+3 -1
View File
@@ -51,7 +51,9 @@ secrets already documented:
| Group | Vars | Needed for | | Group | Vars | Needed for |
|-------|------|-----------| |-------|------|-----------|
| Login / SSO | `BIZGAZE_LOGIN_URL`, `BIZGAZE_DIRECTORY_URL`, `BIZGAZE_DIRECTORY_TOKEN`, `SSO_SECRET` | BizGaze sign-in + directory search | | 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 | | 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 If the VAPID keys are missing, push silently no-ops (the app still runs). Push on
+15
View File
@@ -306,6 +306,21 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id); 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:<userId>' or 'group:<groupId>'. // Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
+117 -27
View File
@@ -1,43 +1,133 @@
// Web Push (background / closed-tab / mobile notifications). Fully optional: // Push notifications — Web Push (browsers/PWA) + native FCM (Android) / APNs (iOS).
// - if the `web-push` package isn't installed, or VAPID env keys aren't set, // Fully optional and additive: each transport is independently config-gated, and every
// isEnabled() is false and every call is a silent no-op (the app is unaffected). // call is a silent best-effort no-op when its credentials aren't set. The app is unaffected
// Configure in production by setting: // if none are configured.
// 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 // 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 R = require('./repos');
let webpush = null; const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ } 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 PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || ''; const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com'; const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let webReady = false;
let ready = false;
if (webpush && PUBLIC && PRIVATE) { 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); } 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; } // ---------------- FCM (Android), HTTP v1 ----------------
function publicKey() { return ready ? PUBLIC : ''; } 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 // ---------------- APNs (iOS), token-based over HTTP/2 ----------------
// (410 Gone / 404) are pruned. Never throws. 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) { async function sendToUser(userId, payload) {
if (!ready) return; if (!webReady && !nativeReady) return;
let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { return; }
const data = JSON.stringify(payload || {}); const data = JSON.stringify(payload || {});
for (const s of subs) { if (webReady) {
const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }; let subs = [];
try { try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; }
await webpush.sendNotification(sub, data, { TTL: 600 }); for (const s of subs) {
} catch (err) { try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
const code = err && err.statusCode; catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
// other errors (network, 4xx) are ignored — push is best-effort }
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 */ }
} }
} }
} }
+10 -1
View File
@@ -290,4 +290,13 @@ const favorites = {
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target), 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 };
+19
View File
@@ -297,6 +297,25 @@ route('POST', '/api/push/unsubscribe', async (req, res) => {
json(res, 200, { ok: true }); 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 ---------- // ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => { route('GET', '/sso', async (req, res) => {
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); } if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
+10
View File
@@ -95,6 +95,16 @@ function nextMsg(ws, type, timeout = 3000) {
const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
check('rotated (old) refresh token is rejected', reuse.status === 401); 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 // 3c. API keys (machine-to-machine integration), scoped + revocable
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie); 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 || '')); check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));