// APNs push helper — direct port of LockInBroAPI's services/push.py. // Uses Node's built-in http2 + crypto: no third-party APNs library. // // Required env vars (names match LockInBroAPI for consistency): // APNS_KEY_ID — 10-char Key ID from Apple Developer portal // APNS_TEAM_ID — 10-char Team ID // APNS_P8_PATH — absolute path to AuthKey_XXXXXXXXXX.p8 inside the container // APPLE_BUNDLE_ID — iOS app bundle ID (becomes apns-topic) // APNS_SANDBOX — "true" for development/TestFlight, anything else = production const http2 = require('http2'); const crypto = require('crypto'); const fs = require('fs'); let cachedJwt = null; let cachedJwtExp = 0; let cachedKeyObj = null; function isConfigured() { return Boolean( process.env.APNS_KEY_ID && process.env.APNS_TEAM_ID && process.env.APNS_P8_PATH && process.env.APPLE_BUNDLE_ID ); } function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); } function makeJwt() { const now = Math.floor(Date.now() / 1000); if (cachedJwt && now < cachedJwtExp) return cachedJwt; if (!cachedKeyObj) { const pem = fs.readFileSync(process.env.APNS_P8_PATH); cachedKeyObj = crypto.createPrivateKey(pem); } const header = b64url(JSON.stringify({ alg: 'ES256', kid: process.env.APNS_KEY_ID })); const payload = b64url(JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: now })); const signingInput = `${header}.${payload}`; // Sign with ES256 — Node returns DER by default; APNs needs P1363 (raw r||s). const sig = crypto.sign('sha256', Buffer.from(signingInput), { key: cachedKeyObj, dsaEncoding: 'ieee-p1363', }); cachedJwt = `${signingInput}.${b64url(sig)}`; cachedJwtExp = now + 3300; // 55 min — APNs rejects tokens older than 60 min return cachedJwt; } // Reuse the HTTP/2 session per host to avoid setup cost on every push. const sessions = {}; function getSession(host) { if (sessions[host] && !sessions[host].closed && !sessions[host].destroyed) { return sessions[host]; } const session = http2.connect(`https://${host}`); session.on('error', (err) => console.error(`[push] APNs session error (${host}):`, err.message)); session.on('close', () => { if (sessions[host] === session) delete sessions[host]; }); sessions[host] = session; return session; } // Posts a single notification to APNs. // `apsPayload` is the full body, e.g. { aps: { alert: { title, body }, sound: 'default' } }. // `pushType` is 'alert' (default), 'background', or 'liveactivity'. async function sendApns(deviceToken, apsPayload, pushType = 'alert') { if (!isConfigured()) { console.warn(`[push] APNs not configured — skipping push to …${deviceToken.slice(-8)}`); return { ok: false, status: 0, body: 'not_configured' }; } const sandbox = process.env.APNS_SANDBOX === 'true'; const host = sandbox ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'; let topic = process.env.APPLE_BUNDLE_ID; if (pushType === 'liveactivity') topic += '.push-type.liveactivity'; const session = getSession(host); const body = Buffer.from(JSON.stringify(apsPayload)); const req = session.request({ ':method': 'POST', ':path': `/3/device/${deviceToken}`, 'authorization': `bearer ${makeJwt()}`, 'apns-topic': topic, 'apns-push-type': pushType, 'apns-priority': '10', 'content-type': 'application/json', 'content-length': body.length, }); return new Promise((resolve) => { let status = 0; let chunks = []; let timeout = setTimeout(() => { req.close(http2.constants.NGHTTP2_CANCEL); }, 10000); req.on('response', (headers) => { status = headers[':status']; }); req.on('data', (c) => chunks.push(c)); req.on('end', () => { clearTimeout(timeout); const respBody = Buffer.concat(chunks).toString(); const tail = deviceToken.slice(-8); if (status === 200) { resolve({ ok: true, status, body: respBody }); } else { console.error(`[push] APNs ${status} for token …${tail}: ${respBody}`); resolve({ ok: false, status, body: respBody }); } }); req.on('error', (err) => { clearTimeout(timeout); console.error(`[push] APNs request error for token …${deviceToken.slice(-8)}:`, err.message); resolve({ ok: false, status: 0, body: err.message }); }); req.end(body); }); } // Public helper used by routes/agenda jobs. `data` is delivered as custom keys // alongside `aps`, matching how the iOS app reads userInfo. async function sendNotification(deviceToken, { title, body, data = {}, pool = null }) { if (!deviceToken) return null; const apsPayload = { aps: { alert: { title, body }, sound: 'default', 'content-available': 1, }, ...data, }; const result = await sendApns(deviceToken, apsPayload, 'alert'); // 410 Unregistered — token is dead, scrub it. if (result.status === 410 && pool) { try { await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [deviceToken]); console.warn(`[push] APNs 410 — cleared dead token …${deviceToken.slice(-8)}`); } catch (err) { console.error('[push] failed to clear dead token:', err.message); } } return result; } function init() { if (!isConfigured()) { console.warn('[push] APNs env not fully set (APNS_KEY_ID/APNS_TEAM_ID/APNS_P8_PATH/APPLE_BUNDLE_ID) — push disabled'); return false; } // Pre-warm key + JWT so the first send doesn't pay the cost. makeJwt(); console.log(`[push] APNs ready (topic=${process.env.APPLE_BUNDLE_ID}, sandbox=${process.env.APNS_SANDBOX === 'true'})`); return true; } function shutdown() { for (const host of Object.keys(sessions)) { try { sessions[host].close(); } catch {} delete sessions[host]; } } module.exports = { init, sendNotification, isConfigured, shutdown };