From 1451c9f57fb9cbd76bf33f7625a8d7028cd2ce86 Mon Sep 17 00:00:00 2001 From: rwarner Date: Fri, 20 Mar 2026 10:33:23 -0400 Subject: [PATCH 1/8] Add iOS Live Activity push support via direct APNs HTTP/2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Activity push tokens from ActivityKit are direct APNs tokens (hex-encoded), not FCM tokens. The existing iOSV1 endpoint cannot handle them — this adds a dedicated iOSLiveActivityV1 endpoint that sends directly to the APNs HTTP/2 API using JWT authentication, bypassing FCM entirely. New files: - apns.js: APNs HTTP/2 client with ES256 JWT auth (Node built-ins only, no new dependencies). JWT cached and rotated every 45 min. HTTP/2 sessions cached per environment (sandbox/production). - live-activity.js: payload builder for start/update/end events. Maps content_state fields to the HALiveActivityAttributes struct expected by the iOS companion app. Sets apns-push-type: liveactivity and routes to correct APNs topic using bundle ID from registration_info. Updated files: - handlers.js: adds handleLiveActivityRequest(), which validates hex APNs tokens, applies rate limiting, sends via apns.js, and handles BadDeviceToken (400) without error reporting. - index.js: exports iOSLiveActivityV1 Cloud Function. - webapp.js: adds /api/sendPush/iOS/liveActivity/v1 route for local dev. Tests: 20 tests across fixture-driven createPayload tests and handleLiveActivityRequest integration tests covering success, token validation, rate limiting, APNs error mapping, and end-event rate-limit bypass. Co-Authored-By: Claude Sonnet 4.6 --- functions/apns.js | 120 +++++++ functions/handlers.js | 99 ++++++ functions/index.js | 8 +- functions/live-activity.js | 87 +++++ .../test/fixtures/live-activity/end.json | 27 ++ .../live-activity/start-push-to-start.json | 34 ++ .../fixtures/live-activity/update-basic.json | 41 +++ .../fixtures/live-activity/update-full.json | 54 +++ functions/test/live-activity.test.js | 331 ++++++++++++++++++ functions/webapp.js | 9 +- 10 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 functions/apns.js create mode 100644 functions/live-activity.js create mode 100644 functions/test/fixtures/live-activity/end.json create mode 100644 functions/test/fixtures/live-activity/start-push-to-start.json create mode 100644 functions/test/fixtures/live-activity/update-basic.json create mode 100644 functions/test/fixtures/live-activity/update-full.json create mode 100644 functions/test/live-activity.test.js diff --git a/functions/apns.js b/functions/apns.js new file mode 100644 index 0000000..84e5c41 --- /dev/null +++ b/functions/apns.js @@ -0,0 +1,120 @@ +'use strict'; + +const crypto = require('crypto'); +const http2 = require('http2'); + +const APNS_HOST_PRODUCTION = 'api.push.apple.com'; +const APNS_HOST_SANDBOX = 'api.sandbox.push.apple.com'; + +// JWT is valid for up to 60 minutes; rotate at 45 to stay well within that window. +const JWT_ROTATION_MS = 45 * 60 * 1000; + +let jwtCache = null; +let jwtGeneratedAt = 0; + +// Cached HTTP/2 sessions keyed by environment. +const sessions = {}; + +function getApnsHost(environment) { + return environment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION; +} + +function generateJWT() { + if (jwtCache && Date.now() - jwtGeneratedAt < JWT_ROTATION_MS) { + return jwtCache; + } + + const teamId = process.env.APNS_TEAM_ID; + const keyId = process.env.APNS_KEY_ID; + const privateKey = (process.env.APNS_PRIVATE_KEY || '').replace(/\\n/g, '\n'); + + if (!teamId || !keyId || !privateKey) { + throw new Error('Missing APNs credentials: APNS_TEAM_ID, APNS_KEY_ID, APNS_PRIVATE_KEY required'); + } + + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: keyId })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iss: teamId, iat: now })).toString('base64url'); + const signingInput = `${header}.${payload}`; + + const sign = crypto.createSign('SHA256'); + sign.update(signingInput); + // ieee-p1363 produces the raw r||s format required for JWT ES256 (not DER-encoded). + const signature = sign.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); + + jwtCache = `${signingInput}.${signature}`; + jwtGeneratedAt = Date.now(); + + return jwtCache; +} + +function getSession(environment) { + const existing = sessions[environment]; + if (existing && !existing.destroyed && !existing.closed) { + return existing; + } + + const host = getApnsHost(environment); + const session = http2.connect(`https://${host}`); + session.on('error', () => { + session.destroy(); + delete sessions[environment]; + }); + sessions[environment] = session; + return session; +} + +/** + * Sends a payload directly to the APNs HTTP/2 API. + * + * @param {string} token - Hex-encoded APNs push token. + * @param {object} payload - APNs JSON payload object. + * @param {object} extraHeaders - Platform-specific APNs headers (apns-push-type, apns-topic, etc.). + * @param {string} environment - 'sandbox' or 'production'. + * @returns {Promise<{ apnsId: string, status: number, body: object }>} + */ +async function send(token, payload, extraHeaders, environment) { + const jwt = generateJWT(); + const host = getApnsHost(environment); + const session = getSession(environment); + const body = JSON.stringify(payload); + + const reqHeaders = { + ':method': 'POST', + ':path': `/3/device/${token}`, + ':scheme': 'https', + ':authority': host, + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + authorization: `bearer ${jwt}`, + ...extraHeaders, + }; + + return new Promise((resolve, reject) => { + const req = session.request(reqHeaders); + + let responseHeaders = {}; + let responseBody = ''; + + req.on('response', (headers) => { + responseHeaders = headers; + }); + + req.on('data', (chunk) => { + responseBody += chunk; + }); + + req.on('end', () => { + const status = responseHeaders[':status']; + const parsedBody = responseBody ? JSON.parse(responseBody) : {}; + resolve({ status, apnsId: responseHeaders['apns-id'] ?? null, body: parsedBody }); + }); + + req.on('error', reject); + + req.write(body); + req.end(); + }); +} + +module.exports = { send }; diff --git a/functions/handlers.js b/functions/handlers.js index adf4677..20a1ad4 100644 --- a/functions/handlers.js +++ b/functions/handlers.js @@ -3,6 +3,7 @@ const { Logging } = require('@google-cloud/logging'); const { getMessaging } = require('firebase-admin/messaging'); const { FirestoreRateLimiter, ValkeyRateLimiter } = require('./rate-limiter'); +const apns = require('./apns'); const MAX_NOTIFICATIONS_PER_DAY = parseInt(process.env.MAX_NOTIFICATIONS_PER_DAY || '500'); const REGION = (process.env.REGION || 'us-central1').toLowerCase(); @@ -346,5 +347,103 @@ function buildLogMetadata(req) { }; } +// APNs push tokens are hex-encoded and never contain a colon. +function isValidApnsToken(token) { + return typeof token === 'string' && /^[0-9a-fA-F]+$/.test(token); +} + +async function handleLiveActivityRequest(req, res, payloadHandler) { + const log = logging.log('handleLiveActivityRequest'); + const metadata = buildLogMetadata(req); + + if (debug) { + log.debug(log.entry(metadata, { message: 'Handling live activity request' })); + } + + const { push_token: token } = req.body; + if (!token) { + return res.status(403).send({ errorMessage: 'You did not send a token!' }); + } + if (!isValidApnsToken(token)) { + return res.status(403).send({ errorMessage: 'That is not a valid APNs token' }); + } + + const { updateRateLimits, apnsPayload, apnsHeaders, apnsEnvironment } = payloadHandler(req); + + let rateLimitInfo; + try { + rateLimitInfo = await rateLimiter.checkRateLimit(token); + } catch (err) { + return handleError(req, res, apnsPayload, 'getRateLimitDoc', err); + } + + if (updateRateLimits) { + const attemptInfo = await rateLimiter.recordAttempt(token); + + if (attemptInfo.isRateLimited) { + return res.status(429).send({ + errorType: 'RateLimited', + message: + 'The given target has reached the maximum number of notifications allowed per day. Please try again later.', + target: token, + rateLimits: attemptInfo.rateLimits, + }); + } + } + + if (debug) { + log.info( + log.entry(metadata, { + message: 'Sending live activity notification', + notification: JSON.stringify(apnsPayload), + }), + ); + } + + let apnsId; + let rateLimits; + try { + const result = await apns.send(token, apnsPayload, apnsHeaders, apnsEnvironment); + if (result.status !== 200) { + const reason = result.body?.reason ?? 'Unknown'; + const err = new Error(`APNs error: ${reason} (HTTP ${result.status})`); + if (result.status === 400 && reason === 'BadDeviceToken') { + if (updateRateLimits) { + await rateLimiter.recordError(token); + } + return res.status(500).send({ + errorType: 'InvalidToken', + errorStep: 'sendNotification', + message: err.message, + }); + } + throw err; + } + apnsId = result.apnsId; + if (updateRateLimits) { + rateLimits = await rateLimiter.recordSuccess(token); + } else { + rateLimits = rateLimitInfo.rateLimits; + } + } catch (err) { + if (updateRateLimits) { + await rateLimiter.recordError(token); + } + return handleError(req, res, apnsPayload, 'sendNotification', err); + } + + if (debug) { + log.info(log.entry(metadata, { message: 'Successfully sent live activity notification', apnsId })); + } + + return res.status(201).send({ + messageId: apnsId, + sentPayload: apnsPayload, + target: token, + rateLimits: rateLimits, + }); +} + exports.handleRequest = handleRequest; exports.handleCheckRateLimits = handleCheckRateLimits; +exports.handleLiveActivityRequest = handleLiveActivityRequest; diff --git a/functions/index.js b/functions/index.js index 2bb8cad..4fa36a9 100644 --- a/functions/index.js +++ b/functions/index.js @@ -9,6 +9,7 @@ initializeApp(); const android = require('./android'); const ios = require('./ios'); const legacy = require('./legacy'); +const liveActivity = require('./live-activity'); const region = (functions.config().app && functions.config().app.region) || 'us-central1'; const regionalFunctions = functions.region(region).runWith({ timeoutSeconds: 10 }); @@ -17,7 +18,7 @@ const regionalFunctions = functions.region(region).runWith({ timeoutSeconds: 10 process.env.DEBUG = isDebug().toString(); process.env.REGION = region; -const { handleRequest, handleCheckRateLimits } = require('./handlers'); +const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = require('./handlers'); exports.androidV1 = regionalFunctions.https.onRequest(async (req, res) => handleRequest(req, res, android.createPayload), @@ -31,6 +32,10 @@ exports.sendPushNotification = regionalFunctions.https.onRequest(async (req, res handleRequest(req, res, legacy.createPayload), ); +exports.iOSLiveActivityV1 = regionalFunctions.https.onRequest(async (req, res) => + handleLiveActivityRequest(req, res, liveActivity.createPayload), +); + exports.checkRateLimits = regionalFunctions.https.onRequest(async (req, res) => handleCheckRateLimits(req, res), ); @@ -45,3 +50,4 @@ function isDebug() { exports.handleRequest = handleRequest; exports.handleCheckRateLimits = handleCheckRateLimits; +exports.handleLiveActivityRequest = handleLiveActivityRequest; diff --git a/functions/live-activity.js b/functions/live-activity.js new file mode 100644 index 0000000..4504294 --- /dev/null +++ b/functions/live-activity.js @@ -0,0 +1,87 @@ +'use strict'; + +// Events that do not count against rate limits (equivalent to clear_notification on Android). +const NO_RATE_LIMIT_EVENTS = new Set(['end']); + +module.exports = { + createPayload: (req) => { + const { data = {} } = req.body; + const event = data.event ?? 'update'; + const now = Math.floor(Date.now() / 1000); + + const aps = { + timestamp: now, + event, + }; + + // content-state is required for start and update; send for end as well so the + // activity can display final state before dismissal. + aps['content-state'] = buildContentState(req.body, data); + + if (event === 'start') { + // Push-to-start requires the static attributes that were registered with the activity. + aps['attributes-type'] = 'HALiveActivityAttributes'; + aps.attributes = { + tag: data.activity_id ?? data.tag ?? '', + title: req.body.title ?? '', + }; + } + + if (event === 'end' && data.dismissal_date) { + aps['dismissal-date'] = data.dismissal_date; + } + + if (data.stale_date) { + aps['stale-date'] = data.stale_date; + } + + if (data.relevance_score !== undefined) { + aps['relevance-score'] = data.relevance_score; + } + + // Optional alert shown alongside the live activity update. + if (data.alert) { + aps.alert = data.alert; + if (data.alert_sound) { + aps.sound = data.alert_sound; + } + } + + const apnsEnvironment = req.body.registration_info?.apns_environment ?? 'production'; + const bundleId = req.body.registration_info?.app_id ?? 'io.robbie.HomeAssistant'; + + return { + updateRateLimits: !NO_RATE_LIMIT_EVENTS.has(event), + apnsPayload: { aps }, + apnsHeaders: { + 'apns-push-type': 'liveactivity', + 'apns-topic': `${bundleId}.push-type.liveactivity`, + 'apns-priority': '10', + }, + apnsEnvironment, + }; + }, +}; + +function buildContentState(body, data) { + const state = {}; + + // Top-level message field is the primary text; content_state fields take precedence. + if (body.message) { + state.message = body.message; + } + + if (data.content_state) { + const cs = data.content_state; + if (cs.message !== undefined) state.message = cs.message; + if (cs.critical_text !== undefined) state.critical_text = cs.critical_text; + if (cs.progress !== undefined) state.progress = cs.progress; + if (cs.progress_max !== undefined) state.progress_max = cs.progress_max; + if (cs.chronometer !== undefined) state.chronometer = cs.chronometer; + if (cs.countdown_end !== undefined) state.countdown_end = cs.countdown_end; + if (cs.icon !== undefined) state.icon = cs.icon; + if (cs.color !== undefined) state.color = cs.color; + } + + return state; +} diff --git a/functions/test/fixtures/live-activity/end.json b/functions/test/fixtures/live-activity/end.json new file mode 100644 index 0000000..e0f773d --- /dev/null +++ b/functions/test/fixtures/live-activity/end.json @@ -0,0 +1,27 @@ +{ + "input": { + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Washer cycle complete", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0", + "apns_environment": "sandbox" + }, + "data": { + "event": "end", + "activity_id": "laundry-001", + "content_state": { + "message": "Washer cycle complete", + "icon": "mdi:washing-machine-off" + }, + "dismissal_date": 1234571490 + } + }, + "expected": { + "updateRateLimits": false, + "apnsEnvironment": "sandbox", + "apsEvent": "end" + } +} diff --git a/functions/test/fixtures/live-activity/start-push-to-start.json b/functions/test/fixtures/live-activity/start-push-to-start.json new file mode 100644 index 0000000..88e8002 --- /dev/null +++ b/functions/test/fixtures/live-activity/start-push-to-start.json @@ -0,0 +1,34 @@ +{ + "input": { + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Laundry started", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.2", + "apns_environment": "sandbox" + }, + "data": { + "event": "start", + "activity_id": "laundry-001", + "content_state": { + "message": "Laundry started", + "progress": 0, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } + }, + "expected": { + "updateRateLimits": true, + "apnsEnvironment": "sandbox", + "apsEvent": "start", + "attributesType": "HALiveActivityAttributes", + "attributes": { + "tag": "laundry-001", + "title": "Laundry" + } + } +} diff --git a/functions/test/fixtures/live-activity/update-basic.json b/functions/test/fixtures/live-activity/update-basic.json new file mode 100644 index 0000000..0f80de3 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-basic.json @@ -0,0 +1,41 @@ +{ + "input": { + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Washer is done", + "title": "Laundry", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0", + "apns_environment": "sandbox" + }, + "data": { + "event": "update", + "activity_id": "laundry-001", + "content_state": { + "message": "Washer is done", + "progress": 3600, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } + }, + "expected": { + "updateRateLimits": true, + "apnsEnvironment": "sandbox", + "apnsHeaders": { + "apns-push-type": "liveactivity", + "apns-topic": "io.robbie.HomeAssistant.push-type.liveactivity", + "apns-priority": "10" + }, + "apsEvent": "update", + "contentState": { + "message": "Washer is done", + "progress": 3600, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" + } + } +} diff --git a/functions/test/fixtures/live-activity/update-full.json b/functions/test/fixtures/live-activity/update-full.json new file mode 100644 index 0000000..33efd38 --- /dev/null +++ b/functions/test/fixtures/live-activity/update-full.json @@ -0,0 +1,54 @@ +{ + "input": { + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "Timer running", + "title": "Kitchen Timer", + "registration_info": { + "app_id": "io.robbie.HomeAssistant", + "app_version": "2024.1", + "os_version": "17.0", + "apns_environment": "production" + }, + "data": { + "event": "update", + "activity_id": "timer-001", + "content_state": { + "message": "45 min remaining", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "countdown_end": "2024-01-01T12:00:00Z", + "icon": "mdi:timer", + "color": "#FF5722" + }, + "stale_date": 1234571490, + "relevance_score": 0.8, + "alert": { + "title": "Timer Update", + "body": "45 minutes remaining" + }, + "alert_sound": "default" + } + }, + "expected": { + "updateRateLimits": true, + "apnsEnvironment": "production", + "apnsHeaders": { + "apns-push-type": "liveactivity", + "apns-topic": "io.robbie.HomeAssistant.push-type.liveactivity", + "apns-priority": "10" + }, + "apsEvent": "update", + "contentState": { + "message": "45 min remaining", + "critical_text": "45 min", + "progress": 2700, + "progress_max": 3600, + "chronometer": true, + "countdown_end": "2024-01-01T12:00:00Z", + "icon": "mdi:timer", + "color": "#FF5722" + } + } +} diff --git a/functions/test/live-activity.test.js b/functions/test/live-activity.test.js new file mode 100644 index 0000000..d641236 --- /dev/null +++ b/functions/test/live-activity.test.js @@ -0,0 +1,331 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const { createMockRequest, createMockResponse, createMockDocRef, createMockRateLimitData, setupFirestoreCollectionChain } = require('./utils/mock-factories'); +const { assertResponse } = require('./utils/assertion-helpers'); + +// --- Mocks --- + +const mockApns = { send: jest.fn() }; +jest.mock('../apns', () => mockApns); + +const mockFirestore = { collection: jest.fn(), runTransaction: jest.fn() }; +const mockLogging = { + log: jest.fn(() => ({ + write: jest.fn((entry, cb) => cb()), + entry: jest.fn(() => ({})), + debug: jest.fn(), + info: jest.fn(), + })), +}; + +jest.mock('@google-cloud/logging', () => ({ Logging: jest.fn(() => mockLogging) })); +jest.mock('firebase-admin/app', () => ({ initializeApp: jest.fn() })); +jest.mock('firebase-admin/firestore', () => ({ + getFirestore: jest.fn(() => mockFirestore), + Timestamp: { fromDate: jest.fn(() => 'mock-timestamp') }, +})); +jest.mock('firebase-admin/messaging', () => ({ + getMessaging: jest.fn(() => ({ send: jest.fn() })), +})); +jest.mock('firebase-functions', () => ({ + config: jest.fn(() => ({})), + region: jest.fn().mockReturnThis(), + runWith: jest.fn().mockReturnThis(), + https: { onRequest: jest.fn() }, +})); + +const { handleLiveActivityRequest } = require('../index.js'); +const liveActivity = require('../live-activity'); + +// --- Helpers --- + +const VALID_APNS_TOKEN = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + +// Pass body field overrides directly (not nested under body: {}) to avoid +// createMockRequest's outer Object.assign overwriting the merged body. +function createLiveActivityRequest(bodyOverrides = {}) { + return createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + message: 'Test message', + title: 'Test title', + registration_info: { + app_id: 'io.robbie.HomeAssistant', + app_version: '2024.1', + os_version: '17.0', + apns_environment: 'sandbox', + }, + data: { + event: 'update', + activity_id: 'test-001', + content_state: { message: 'Test message' }, + }, + ...bodyOverrides, + }, + }); +} + +function setupFirestoreMocks() { + const docSnapshot = { exists: false, data: jest.fn(() => createMockRateLimitData()) }; + const docRef = createMockDocRef(docSnapshot); + setupFirestoreCollectionChain(mockFirestore, docRef); + + mockFirestore.runTransaction.mockImplementation(async (callback) => { + let exists = docSnapshot.exists; + let currentData = exists ? { ...docSnapshot.data() } : null; + + const mockTxn = { + get: jest.fn().mockImplementation(() => ({ exists, data: () => currentData || {} })), + set: jest.fn().mockImplementation((ref, data) => { + exists = true; + currentData = { ...data }; + docSnapshot.exists = true; + docSnapshot.data = jest.fn(() => currentData); + docRef.set(data); + }), + update: jest.fn().mockImplementation((ref, data) => { + if (currentData) { + currentData = { ...currentData, ...data }; + docSnapshot.data = jest.fn(() => currentData); + } + docRef.update(data); + }), + }; + + return callback(mockTxn); + }); + + return { docRef, docSnapshot }; +} + +// --- createPayload tests (fixture-driven) --- + +const fixturesDir = path.join(__dirname, 'fixtures/live-activity'); +const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.json')); + +describe('live-activity createPayload', () => { + it.each(fixtureFiles)('%s', (filename) => { + const fixture = JSON.parse(fs.readFileSync(path.join(fixturesDir, filename), 'utf8')); + const req = createMockRequest({ body: fixture.input }); + const result = liveActivity.createPayload(req); + + expect(result.updateRateLimits).toBe(fixture.expected.updateRateLimits); + expect(result.apnsEnvironment).toBe(fixture.expected.apnsEnvironment); + + if (fixture.expected.apnsHeaders) { + expect(result.apnsHeaders).toMatchObject(fixture.expected.apnsHeaders); + } + + expect(result.apnsPayload.aps.event).toBe(fixture.expected.apsEvent); + expect(typeof result.apnsPayload.aps.timestamp).toBe('number'); + + if (fixture.expected.contentState) { + expect(result.apnsPayload.aps['content-state']).toMatchObject(fixture.expected.contentState); + } + + if (fixture.expected.attributesType) { + expect(result.apnsPayload.aps['attributes-type']).toBe(fixture.expected.attributesType); + } + + if (fixture.expected.attributes) { + expect(result.apnsPayload.aps.attributes).toMatchObject(fixture.expected.attributes); + } + }); +}); + +// --- handleLiveActivityRequest integration tests --- + +describe('handleLiveActivityRequest', () => { + let res; + + beforeEach(() => { + jest.clearAllMocks(); + mockApns.send.mockResolvedValue({ status: 200, apnsId: 'mock-apns-id', body: {} }); + res = createMockResponse(); + setupFirestoreMocks(); + }); + + test('sends successfully and returns 201', async () => { + const req = createLiveActivityRequest(); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + expect(mockApns.send).toHaveBeenCalledTimes(1); + const [sentToken, sentPayload, sentHeaders, sentEnv] = mockApns.send.mock.calls[0]; + expect(sentToken).toBe(VALID_APNS_TOKEN); + expect(sentPayload.aps.event).toBe('update'); + expect(sentHeaders['apns-push-type']).toBe('liveactivity'); + expect(sentEnv).toBe('sandbox'); + + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + expect(response.messageId).toBe('mock-apns-id'); + expect(response.target).toBe(VALID_APNS_TOKEN); + expect(response.rateLimits).toBeDefined(); + }); + + test('rejects missing token with 403', async () => { + const req = createLiveActivityRequest({ push_token: undefined }); + delete req.body.push_token; + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectForbiddenResponse(res, 'You did not send a token!'); + expect(mockApns.send).not.toHaveBeenCalled(); + }); + + test('rejects FCM token (contains colon) with 403', async () => { + const req = createLiveActivityRequest({ push_token: 'fcm:token123' }); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); + expect(mockApns.send).not.toHaveBeenCalled(); + }); + + test('rejects non-hex token with 403', async () => { + const req = createLiveActivityRequest({ push_token: 'not-a-valid-token!!' }); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); + expect(mockApns.send).not.toHaveBeenCalled(); + }); + + test('does not update rate limits for end events', async () => { + const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + expect(mockApns.send).toHaveBeenCalledTimes(1); + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + // Rate limits should still be present (from checkRateLimit), just not incremented + expect(response.rateLimits).toBeDefined(); + }); + + test('returns 500 InvalidToken on APNs BadDeviceToken', async () => { + mockApns.send.mockResolvedValue({ status: 400, apnsId: null, body: { reason: 'BadDeviceToken' } }); + const req = createLiveActivityRequest(); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectErrorResponse(res, 500, { errorType: 'InvalidToken' }); + }); + + test('returns 500 InternalError on APNs send failure', async () => { + mockApns.send.mockRejectedValue(new Error('Network error')); + const req = createLiveActivityRequest(); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectErrorResponse(res, 500, { + errorType: 'InternalError', + errorStep: 'sendNotification', + }); + }); + + test('returns 500 InternalError on unexpected APNs status', async () => { + mockApns.send.mockResolvedValue({ status: 500, apnsId: null, body: { reason: 'InternalServerError' } }); + const req = createLiveActivityRequest(); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectErrorResponse(res, 500, { errorType: 'InternalError' }); + }); + + test('returns 429 when rate limited', async () => { + const { docSnapshot } = setupFirestoreMocks(); + docSnapshot.exists = true; + docSnapshot.data.mockReturnValue( + createMockRateLimitData({ attemptsCount: 501, deliveredCount: 501, totalCount: 501 }), + ); + + const req = createLiveActivityRequest(); + await handleLiveActivityRequest(req, res, liveActivity.createPayload); + + assertResponse.expectRateLimitResponse(res, VALID_APNS_TOKEN); + expect(mockApns.send).not.toHaveBeenCalled(); + }); +}); + +// --- createPayload unit tests --- + +describe('live-activity createPayload unit', () => { + test('defaults event to update when not specified', () => { + const req = createLiveActivityRequest({ body: { data: {} } }); + const { apnsPayload } = liveActivity.createPayload(req); + expect(apnsPayload.aps.event).toBe('update'); + }); + + test('defaults apnsEnvironment to production when not specified', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update' }, + }, + }); + const { apnsEnvironment } = liveActivity.createPayload(req); + expect(apnsEnvironment).toBe('production'); + }); + + test('start event includes attributes-type and attributes', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + title: 'Laundry', + registration_info: { app_id: 'io.robbie.HomeAssistant', apns_environment: 'sandbox' }, + data: { event: 'start', activity_id: 'laundry-001' }, + }, + }); + const { apnsPayload } = liveActivity.createPayload(req); + expect(apnsPayload.aps['attributes-type']).toBe('HALiveActivityAttributes'); + expect(apnsPayload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); + }); + + test('end event includes dismissal-date when provided', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'end', dismissal_date: 9999999 }, + }, + }); + const { apnsPayload } = liveActivity.createPayload(req); + expect(apnsPayload.aps['dismissal-date']).toBe(9999999); + }); + + test('stale-date and relevance-score are included when provided', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update', stale_date: 1111, relevance_score: 0.5 }, + }, + }); + const { apnsPayload } = liveActivity.createPayload(req); + expect(apnsPayload.aps['stale-date']).toBe(1111); + expect(apnsPayload.aps['relevance-score']).toBe(0.5); + }); + + test('apns-topic uses bundle id from registration_info', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + registration_info: { app_id: 'com.example.app', apns_environment: 'production' }, + data: { event: 'update' }, + }, + }); + const { apnsHeaders } = liveActivity.createPayload(req); + expect(apnsHeaders['apns-topic']).toBe('com.example.app.push-type.liveactivity'); + }); + + test('top-level message is used when no content_state', () => { + const req = createMockRequest({ + body: { + push_token: VALID_APNS_TOKEN, + message: 'Hello from HA', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { event: 'update' }, + }, + }); + const { apnsPayload } = liveActivity.createPayload(req); + expect(apnsPayload.aps['content-state'].message).toBe('Hello from HA'); + }); +}); diff --git a/functions/webapp.js b/functions/webapp.js index 52464c8..24e9e1e 100644 --- a/functions/webapp.js +++ b/functions/webapp.js @@ -8,11 +8,12 @@ const { loggerConfig } = require('./fastify-logger'); const fastify = require('fastify')({ logger: loggerConfig, trustProxy: true }); // Import the functions from index.js -const { handleRequest, handleCheckRateLimits } = require('./handlers'); +const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = require('./handlers'); const android = require('./android'); const ios = require('./ios'); const legacy = require('./legacy'); +const liveActivity = require('./live-activity'); // Cloud Functions adapter function createCloudFunctionsAdapter(request, reply) { @@ -48,6 +49,11 @@ async function handleSendPushNotification(request, reply) { return handleRequest(req, res, legacy.createPayload); } +async function handleIOSLiveActivityV1(request, reply) { + const { req, res } = createCloudFunctionsAdapter(request, reply); + return handleLiveActivityRequest(req, res, liveActivity.createPayload); +} + async function checkRateLimits(request, reply) { const { req, res } = createCloudFunctionsAdapter(request, reply); return handleCheckRateLimits(req, res); @@ -56,6 +62,7 @@ async function checkRateLimits(request, reply) { // Register routes fastify.post('/api/sendPush/android/v1', handleAndroidV1); fastify.post('/api/sendPush/iOS/v1', handleIOSV1); +fastify.post('/api/sendPush/iOS/liveActivity/v1', handleIOSLiveActivityV1); fastify.post('/api/sendPushNotification', handleSendPushNotification); fastify.post('/api/checkRateLimits', checkRateLimits); From 7dc11f7e8b62c09cacf35b7f814252380d76df83 Mon Sep 17 00:00:00 2001 From: rwarner Date: Fri, 20 Mar 2026 10:38:07 -0400 Subject: [PATCH 2/8] Consolidate Live Activity payload builder into ios.js Moves createLiveActivityPayload() from the standalone live-activity.js into ios.js as a second export, keeping all iOS-specific logic in one place consistent with the existing android.js/ios.js pattern. Co-Authored-By: Claude Sonnet 4.6 --- functions/index.js | 3 +- functions/ios.js | 86 +++++++++++++++++++++++++++ functions/live-activity.js | 87 ---------------------------- functions/test/live-activity.test.js | 36 ++++++------ functions/webapp.js | 3 +- 5 files changed, 106 insertions(+), 109 deletions(-) delete mode 100644 functions/live-activity.js diff --git a/functions/index.js b/functions/index.js index 4fa36a9..0980cfc 100644 --- a/functions/index.js +++ b/functions/index.js @@ -9,7 +9,6 @@ initializeApp(); const android = require('./android'); const ios = require('./ios'); const legacy = require('./legacy'); -const liveActivity = require('./live-activity'); const region = (functions.config().app && functions.config().app.region) || 'us-central1'; const regionalFunctions = functions.region(region).runWith({ timeoutSeconds: 10 }); @@ -33,7 +32,7 @@ exports.sendPushNotification = regionalFunctions.https.onRequest(async (req, res ); exports.iOSLiveActivityV1 = regionalFunctions.https.onRequest(async (req, res) => - handleLiveActivityRequest(req, res, liveActivity.createPayload), + handleLiveActivityRequest(req, res, ios.createLiveActivityPayload), ); exports.checkRateLimits = regionalFunctions.https.onRequest(async (req, res) => diff --git a/functions/ios.js b/functions/ios.js index 4c9ec25..6373c10 100644 --- a/functions/ios.js +++ b/functions/ios.js @@ -1,3 +1,8 @@ +'use strict'; + +// Events that do not count against rate limits (equivalent to clear_notification on Android). +const NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS = new Set(['end']); + module.exports = { createPayload: (req) => { const payload = { @@ -141,4 +146,85 @@ module.exports = { return { updateRateLimits, payload }; }, + + createLiveActivityPayload: (req) => { + const { data = {} } = req.body; + const event = data.event ?? 'update'; + const now = Math.floor(Date.now() / 1000); + + const aps = { + timestamp: now, + event, + }; + + // content-state is required for start and update; send for end as well so the + // activity can display final state before dismissal. + aps['content-state'] = buildLiveActivityContentState(req.body, data); + + if (event === 'start') { + // Push-to-start requires the static attributes that were registered with the activity. + aps['attributes-type'] = 'HALiveActivityAttributes'; + aps.attributes = { + tag: data.activity_id ?? data.tag ?? '', + title: req.body.title ?? '', + }; + } + + if (event === 'end' && data.dismissal_date) { + aps['dismissal-date'] = data.dismissal_date; + } + + if (data.stale_date) { + aps['stale-date'] = data.stale_date; + } + + if (data.relevance_score !== undefined) { + aps['relevance-score'] = data.relevance_score; + } + + // Optional alert shown alongside the live activity update. + if (data.alert) { + aps.alert = data.alert; + if (data.alert_sound) { + aps.sound = data.alert_sound; + } + } + + const apnsEnvironment = req.body.registration_info?.apns_environment ?? 'production'; + const bundleId = req.body.registration_info?.app_id ?? 'io.robbie.HomeAssistant'; + + return { + updateRateLimits: !NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS.has(event), + apnsPayload: { aps }, + apnsHeaders: { + 'apns-push-type': 'liveactivity', + 'apns-topic': `${bundleId}.push-type.liveactivity`, + 'apns-priority': '10', + }, + apnsEnvironment, + }; + }, }; + +function buildLiveActivityContentState(body, data) { + const state = {}; + + // Top-level message field is the primary text; content_state fields take precedence. + if (body.message) { + state.message = body.message; + } + + if (data.content_state) { + const cs = data.content_state; + if (cs.message !== undefined) state.message = cs.message; + if (cs.critical_text !== undefined) state.critical_text = cs.critical_text; + if (cs.progress !== undefined) state.progress = cs.progress; + if (cs.progress_max !== undefined) state.progress_max = cs.progress_max; + if (cs.chronometer !== undefined) state.chronometer = cs.chronometer; + if (cs.countdown_end !== undefined) state.countdown_end = cs.countdown_end; + if (cs.icon !== undefined) state.icon = cs.icon; + if (cs.color !== undefined) state.color = cs.color; + } + + return state; +} diff --git a/functions/live-activity.js b/functions/live-activity.js deleted file mode 100644 index 4504294..0000000 --- a/functions/live-activity.js +++ /dev/null @@ -1,87 +0,0 @@ -'use strict'; - -// Events that do not count against rate limits (equivalent to clear_notification on Android). -const NO_RATE_LIMIT_EVENTS = new Set(['end']); - -module.exports = { - createPayload: (req) => { - const { data = {} } = req.body; - const event = data.event ?? 'update'; - const now = Math.floor(Date.now() / 1000); - - const aps = { - timestamp: now, - event, - }; - - // content-state is required for start and update; send for end as well so the - // activity can display final state before dismissal. - aps['content-state'] = buildContentState(req.body, data); - - if (event === 'start') { - // Push-to-start requires the static attributes that were registered with the activity. - aps['attributes-type'] = 'HALiveActivityAttributes'; - aps.attributes = { - tag: data.activity_id ?? data.tag ?? '', - title: req.body.title ?? '', - }; - } - - if (event === 'end' && data.dismissal_date) { - aps['dismissal-date'] = data.dismissal_date; - } - - if (data.stale_date) { - aps['stale-date'] = data.stale_date; - } - - if (data.relevance_score !== undefined) { - aps['relevance-score'] = data.relevance_score; - } - - // Optional alert shown alongside the live activity update. - if (data.alert) { - aps.alert = data.alert; - if (data.alert_sound) { - aps.sound = data.alert_sound; - } - } - - const apnsEnvironment = req.body.registration_info?.apns_environment ?? 'production'; - const bundleId = req.body.registration_info?.app_id ?? 'io.robbie.HomeAssistant'; - - return { - updateRateLimits: !NO_RATE_LIMIT_EVENTS.has(event), - apnsPayload: { aps }, - apnsHeaders: { - 'apns-push-type': 'liveactivity', - 'apns-topic': `${bundleId}.push-type.liveactivity`, - 'apns-priority': '10', - }, - apnsEnvironment, - }; - }, -}; - -function buildContentState(body, data) { - const state = {}; - - // Top-level message field is the primary text; content_state fields take precedence. - if (body.message) { - state.message = body.message; - } - - if (data.content_state) { - const cs = data.content_state; - if (cs.message !== undefined) state.message = cs.message; - if (cs.critical_text !== undefined) state.critical_text = cs.critical_text; - if (cs.progress !== undefined) state.progress = cs.progress; - if (cs.progress_max !== undefined) state.progress_max = cs.progress_max; - if (cs.chronometer !== undefined) state.chronometer = cs.chronometer; - if (cs.countdown_end !== undefined) state.countdown_end = cs.countdown_end; - if (cs.icon !== undefined) state.icon = cs.icon; - if (cs.color !== undefined) state.color = cs.color; - } - - return state; -} diff --git a/functions/test/live-activity.test.js b/functions/test/live-activity.test.js index d641236..661a3e0 100644 --- a/functions/test/live-activity.test.js +++ b/functions/test/live-activity.test.js @@ -38,7 +38,7 @@ jest.mock('firebase-functions', () => ({ })); const { handleLiveActivityRequest } = require('../index.js'); -const liveActivity = require('../live-activity'); +const ios = require('../ios'); // --- Helpers --- @@ -110,7 +110,7 @@ describe('live-activity createPayload', () => { it.each(fixtureFiles)('%s', (filename) => { const fixture = JSON.parse(fs.readFileSync(path.join(fixturesDir, filename), 'utf8')); const req = createMockRequest({ body: fixture.input }); - const result = liveActivity.createPayload(req); + const result = ios.createLiveActivityPayload(req); expect(result.updateRateLimits).toBe(fixture.expected.updateRateLimits); expect(result.apnsEnvironment).toBe(fixture.expected.apnsEnvironment); @@ -150,7 +150,7 @@ describe('handleLiveActivityRequest', () => { test('sends successfully and returns 201', async () => { const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); expect(mockApns.send).toHaveBeenCalledTimes(1); const [sentToken, sentPayload, sentHeaders, sentEnv] = mockApns.send.mock.calls[0]; @@ -169,7 +169,7 @@ describe('handleLiveActivityRequest', () => { test('rejects missing token with 403', async () => { const req = createLiveActivityRequest({ push_token: undefined }); delete req.body.push_token; - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectForbiddenResponse(res, 'You did not send a token!'); expect(mockApns.send).not.toHaveBeenCalled(); @@ -177,7 +177,7 @@ describe('handleLiveActivityRequest', () => { test('rejects FCM token (contains colon) with 403', async () => { const req = createLiveActivityRequest({ push_token: 'fcm:token123' }); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); expect(mockApns.send).not.toHaveBeenCalled(); @@ -185,7 +185,7 @@ describe('handleLiveActivityRequest', () => { test('rejects non-hex token with 403', async () => { const req = createLiveActivityRequest({ push_token: 'not-a-valid-token!!' }); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); expect(mockApns.send).not.toHaveBeenCalled(); @@ -193,7 +193,7 @@ describe('handleLiveActivityRequest', () => { test('does not update rate limits for end events', async () => { const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); expect(mockApns.send).toHaveBeenCalledTimes(1); assertResponse.expectSuccessResponse(res); @@ -205,7 +205,7 @@ describe('handleLiveActivityRequest', () => { test('returns 500 InvalidToken on APNs BadDeviceToken', async () => { mockApns.send.mockResolvedValue({ status: 400, apnsId: null, body: { reason: 'BadDeviceToken' } }); const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectErrorResponse(res, 500, { errorType: 'InvalidToken' }); }); @@ -213,7 +213,7 @@ describe('handleLiveActivityRequest', () => { test('returns 500 InternalError on APNs send failure', async () => { mockApns.send.mockRejectedValue(new Error('Network error')); const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectErrorResponse(res, 500, { errorType: 'InternalError', @@ -224,7 +224,7 @@ describe('handleLiveActivityRequest', () => { test('returns 500 InternalError on unexpected APNs status', async () => { mockApns.send.mockResolvedValue({ status: 500, apnsId: null, body: { reason: 'InternalServerError' } }); const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectErrorResponse(res, 500, { errorType: 'InternalError' }); }); @@ -237,7 +237,7 @@ describe('handleLiveActivityRequest', () => { ); const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, liveActivity.createPayload); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); assertResponse.expectRateLimitResponse(res, VALID_APNS_TOKEN); expect(mockApns.send).not.toHaveBeenCalled(); @@ -249,7 +249,7 @@ describe('handleLiveActivityRequest', () => { describe('live-activity createPayload unit', () => { test('defaults event to update when not specified', () => { const req = createLiveActivityRequest({ body: { data: {} } }); - const { apnsPayload } = liveActivity.createPayload(req); + const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps.event).toBe('update'); }); @@ -261,7 +261,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'update' }, }, }); - const { apnsEnvironment } = liveActivity.createPayload(req); + const { apnsEnvironment } = ios.createLiveActivityPayload(req); expect(apnsEnvironment).toBe('production'); }); @@ -274,7 +274,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'start', activity_id: 'laundry-001' }, }, }); - const { apnsPayload } = liveActivity.createPayload(req); + const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps['attributes-type']).toBe('HALiveActivityAttributes'); expect(apnsPayload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); }); @@ -287,7 +287,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'end', dismissal_date: 9999999 }, }, }); - const { apnsPayload } = liveActivity.createPayload(req); + const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps['dismissal-date']).toBe(9999999); }); @@ -299,7 +299,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'update', stale_date: 1111, relevance_score: 0.5 }, }, }); - const { apnsPayload } = liveActivity.createPayload(req); + const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps['stale-date']).toBe(1111); expect(apnsPayload.aps['relevance-score']).toBe(0.5); }); @@ -312,7 +312,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'update' }, }, }); - const { apnsHeaders } = liveActivity.createPayload(req); + const { apnsHeaders } = ios.createLiveActivityPayload(req); expect(apnsHeaders['apns-topic']).toBe('com.example.app.push-type.liveactivity'); }); @@ -325,7 +325,7 @@ describe('live-activity createPayload unit', () => { data: { event: 'update' }, }, }); - const { apnsPayload } = liveActivity.createPayload(req); + const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps['content-state'].message).toBe('Hello from HA'); }); }); diff --git a/functions/webapp.js b/functions/webapp.js index 24e9e1e..52d0387 100644 --- a/functions/webapp.js +++ b/functions/webapp.js @@ -13,7 +13,6 @@ const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = requ const android = require('./android'); const ios = require('./ios'); const legacy = require('./legacy'); -const liveActivity = require('./live-activity'); // Cloud Functions adapter function createCloudFunctionsAdapter(request, reply) { @@ -51,7 +50,7 @@ async function handleSendPushNotification(request, reply) { async function handleIOSLiveActivityV1(request, reply) { const { req, res } = createCloudFunctionsAdapter(request, reply); - return handleLiveActivityRequest(req, res, liveActivity.createPayload); + return handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); } async function checkRateLimits(request, reply) { From 7273578966c06af89505e09a15455fcd18289059 Mon Sep 17 00:00:00 2001 From: rwarner Date: Fri, 20 Mar 2026 10:38:51 -0400 Subject: [PATCH 3/8] Rename live-activity.test.js to ios.test.js Follows the existing test naming convention (legacy.test.js -> legacy.js) now that the Live Activity payload builder lives in ios.js. Co-Authored-By: Claude Sonnet 4.6 --- functions/test/{live-activity.test.js => ios.test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename functions/test/{live-activity.test.js => ios.test.js} (100%) diff --git a/functions/test/live-activity.test.js b/functions/test/ios.test.js similarity index 100% rename from functions/test/live-activity.test.js rename to functions/test/ios.test.js From 4bbfeef300a729a2ea5a954c2791fd86aca502d5 Mon Sep 17 00:00:00 2001 From: rwarner Date: Fri, 20 Mar 2026 10:56:46 -0400 Subject: [PATCH 4/8] Address Copilot review feedback and add explanatory comments - Fix misleading comment in webapp.js (was "Import the functions from index.js") - Validate APNs token length (must be exactly 64 hex chars) in isValidApnsToken; add test - Normalize apnsEnvironment to 'sandbox'|'production' to prevent unbounded session cache growth - Guard JSON.parse in apns.js against non-JSON APNs responses - Fix test: pass body overrides flat to createLiveActivityRequest (not nested under body:) - Add explanatory comments throughout apns.js, ios.js, and handlers.js for non-obvious decisions Co-Authored-By: Claude Sonnet 4.6 --- functions/apns.js | 30 +++++++++++++++++++++++++++++- functions/handlers.js | 17 +++++++++++++++-- functions/ios.js | 22 ++++++++++++++++++++-- functions/test/ios.test.js | 11 ++++++++++- functions/webapp.js | 2 +- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/functions/apns.js b/functions/apns.js index 84e5c41..7402e7b 100644 --- a/functions/apns.js +++ b/functions/apns.js @@ -1,5 +1,17 @@ 'use strict'; +// Direct APNs HTTP/2 client used for iOS Live Activity push notifications. +// +// Live Activity push tokens are hex-encoded direct APNs tokens — they are NOT FCM tokens. +// FCM tokens always contain a colon and are routed through Firebase; Live Activity tokens +// bypass Firebase entirely and must be delivered directly to api.push.apple.com via HTTP/2. +// Additionally, FCM does not support apns-push-type: liveactivity, so this path is required. +// +// Required environment variables: +// APNS_TEAM_ID - 10-character Apple Developer Team ID +// APNS_KEY_ID - 10-character key ID for the .p8 signing key +// APNS_PRIVATE_KEY - Contents of the .p8 file (newlines may be escaped as \n) + const crypto = require('crypto'); const http2 = require('http2'); @@ -48,6 +60,11 @@ function generateJWT() { return jwtCache; } +// Returns a cached HTTP/2 session for the given APNs environment. +// Sessions are cached at module level so warm Cloud Function instances reuse the same +// persistent connection rather than re-establishing a TLS handshake on every request. +// Sandbox and production use separate sessions because they connect to different hosts; +// APNs will reject a production token sent to the sandbox endpoint and vice versa. function getSession(environment) { const existing = sessions[environment]; if (existing && !existing.destroyed && !existing.closed) { @@ -57,6 +74,7 @@ function getSession(environment) { const host = getApnsHost(environment); const session = http2.connect(`https://${host}`); session.on('error', () => { + // Clean up the cached reference so the next call creates a fresh session. session.destroy(); delete sessions[environment]; }); @@ -106,7 +124,17 @@ async function send(token, payload, extraHeaders, environment) { req.on('end', () => { const status = responseHeaders[':status']; - const parsedBody = responseBody ? JSON.parse(responseBody) : {}; + let parsedBody; + if (!responseBody) { + parsedBody = {}; + } else { + try { + parsedBody = JSON.parse(responseBody); + } catch (e) { + // APNs or an intermediary returned a non-JSON body; preserve the raw text. + parsedBody = { raw: responseBody }; + } + } resolve({ status, apnsId: responseHeaders['apns-id'] ?? null, body: parsedBody }); }); diff --git a/functions/handlers.js b/functions/handlers.js index 20a1ad4..90c5b35 100644 --- a/functions/handlers.js +++ b/functions/handlers.js @@ -347,11 +347,20 @@ function buildLogMetadata(req) { }; } -// APNs push tokens are hex-encoded and never contain a colon. +// APNs push tokens are hex-encoded device tokens and never contain a colon. +// handleRequest uses a colon check to distinguish FCM tokens from legacy SNS tokens; +// we use a hex-only check here because Live Activity tokens are direct APNs tokens. +// APNs device tokens are fixed-length: 32 bytes, hex-encoded as 64 characters. function isValidApnsToken(token) { - return typeof token === 'string' && /^[0-9a-fA-F]+$/.test(token); + return typeof token === 'string' && token.length === 64 && /^[0-9a-fA-F]+$/.test(token); } +// Handles Live Activity push requests delivered directly to APNs via HTTP/2. +// +// This is intentionally separate from handleRequest for two reasons: +// 1. Token format: Live Activity tokens are hex APNs tokens, not FCM tokens with a colon. +// 2. Delivery path: Live Activities require apns-push-type: liveactivity, which FCM does +// not support. The payload goes straight to APNs via apns.js, not through messaging.send(). async function handleLiveActivityRequest(req, res, payloadHandler) { const log = logging.log('handleLiveActivityRequest'); const metadata = buildLogMetadata(req); @@ -407,6 +416,10 @@ async function handleLiveActivityRequest(req, res, payloadHandler) { if (result.status !== 200) { const reason = result.body?.reason ?? 'Unknown'; const err = new Error(`APNs error: ${reason} (HTTP ${result.status})`); + // BadDeviceToken means the token is invalid or the activity has already ended. + // This is expected (not a server bug), so we return an InvalidToken response + // without logging to Cloud Error Reporting — same pattern as handleRequest uses + // for FCM's invalid-registration-token error code. if (result.status === 400 && reason === 'BadDeviceToken') { if (updateRateLimits) { await rateLimiter.recordError(token); diff --git a/functions/ios.js b/functions/ios.js index 6373c10..0fa2dc1 100644 --- a/functions/ios.js +++ b/functions/ios.js @@ -1,6 +1,7 @@ 'use strict'; -// Events that do not count against rate limits (equivalent to clear_notification on Android). +// Live Activity 'end' events dismiss an ongoing activity rather than delivering new content, +// so they are exempt from rate limits — equivalent to clear_notification on Android. const NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS = new Set(['end']); module.exports = { @@ -147,6 +148,11 @@ module.exports = { return { updateRateLimits, payload }; }, + // Builds the APNs payload for a Live Activity push notification. + // + // This returns a different shape than createPayload because Live Activities bypass FCM + // entirely — the payload is delivered directly to APNs via apns.js. The returned object + // contains apnsPayload/apnsHeaders/apnsEnvironment instead of an FCM message object. createLiveActivityPayload: (req) => { const { data = {} } = req.body; const event = data.event ?? 'update'; @@ -163,6 +169,9 @@ module.exports = { if (event === 'start') { // Push-to-start requires the static attributes that were registered with the activity. + // 'attributes-type' must exactly match the Swift struct name — HALiveActivityAttributes — + // because APNs uses it to look up the registered ActivityKit type on the device. + // This value is case-sensitive and cannot change after the app has shipped. aps['attributes-type'] = 'HALiveActivityAttributes'; aps.attributes = { tag: data.activity_id ?? data.tag ?? '', @@ -190,7 +199,12 @@ module.exports = { } } - const apnsEnvironment = req.body.registration_info?.apns_environment ?? 'production'; + // Sandbox tokens are rejected by the production APNs endpoint and vice versa. + // The client reports its environment during registration so we can route correctly. + // Normalize to the two valid values so unexpected strings don't create unbounded + // session cache entries in apns.js (e.g. 'Production', 'prod', or typos). + const rawEnvironment = req.body.registration_info?.apns_environment; + const apnsEnvironment = rawEnvironment === 'sandbox' ? 'sandbox' : 'production'; const bundleId = req.body.registration_info?.app_id ?? 'io.robbie.HomeAssistant'; return { @@ -198,6 +212,7 @@ module.exports = { apnsPayload: { aps }, apnsHeaders: { 'apns-push-type': 'liveactivity', + // APNs requires the topic to include the push-type suffix for Live Activities. 'apns-topic': `${bundleId}.push-type.liveactivity`, 'apns-priority': '10', }, @@ -206,6 +221,9 @@ module.exports = { }, }; +// Builds the content-state object that APNs delivers to the app's Live Activity widget. +// Each field maps to a property in the Swift HALiveActivityContentState Codable struct. +// Only recognized fields are forwarded — extra keys would cause APNs to reject the payload. function buildLiveActivityContentState(body, data) { const state = {}; diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js index 661a3e0..eb3b296 100644 --- a/functions/test/ios.test.js +++ b/functions/test/ios.test.js @@ -191,6 +191,15 @@ describe('handleLiveActivityRequest', () => { expect(mockApns.send).not.toHaveBeenCalled(); }); + test('rejects hex token with wrong length with 403', async () => { + // APNs tokens are exactly 64 hex chars (32 bytes); shorter values are invalid. + const req = createLiveActivityRequest({ push_token: 'a1b2c3d4' }); + await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); + + assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); + expect(mockApns.send).not.toHaveBeenCalled(); + }); + test('does not update rate limits for end events', async () => { const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); @@ -248,7 +257,7 @@ describe('handleLiveActivityRequest', () => { describe('live-activity createPayload unit', () => { test('defaults event to update when not specified', () => { - const req = createLiveActivityRequest({ body: { data: {} } }); + const req = createLiveActivityRequest({ data: {} }); const { apnsPayload } = ios.createLiveActivityPayload(req); expect(apnsPayload.aps.event).toBe('update'); }); diff --git a/functions/webapp.js b/functions/webapp.js index 52d0387..1e0d786 100644 --- a/functions/webapp.js +++ b/functions/webapp.js @@ -7,7 +7,7 @@ initializeApp(); const { loggerConfig } = require('./fastify-logger'); const fastify = require('fastify')({ logger: loggerConfig, trustProxy: true }); -// Import the functions from index.js +// Import the handlers const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = require('./handlers'); const android = require('./android'); From 90a1e28a99d92f8894eb48ffb22be48595ed4a9c Mon Sep 17 00:00:00 2001 From: rwarner Date: Fri, 20 Mar 2026 23:21:27 -0400 Subject: [PATCH 5/8] Refactor Live Activities to use FCM native liveActivityToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firebase Admin SDK v13.5.0+ supports the liveActivityToken field in the apns config object, which tells FCM to automatically set apns-push-type: liveactivity and route the notification to APNs. This eliminates the need for a custom direct APNs HTTP/2 client. Changes: - Delete apns.js (custom HTTP/2 + JWT APNs client) — no longer needed - Bump firebase-admin from ^12.1.0 to ^13.5.0 - Rewrite ios.js: createPayload detects live_activity_token in request body and builds FCM payload with apns.liveActivityToken (camelCase) - Remove handleLiveActivityRequest from handlers.js — existing handleRequest + messaging.send() handles everything - Remove iOSLiveActivityV1 endpoint from index.js and webapp.js - Rewrite tests for FCM-based delivery path - Update fixtures to use FCM payload structure The relay server no longer needs APNS_TEAM_ID, APNS_KEY_ID, or APNS_PRIVATE_KEY environment variables. HA core sends both the FCM registration token (push_token) and the Live Activity APNs token (live_activity_token) to the same /api/sendPush/iOS/v1 endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- functions/apns.js | 148 ------- functions/handlers.js | 112 ----- functions/index.js | 7 +- functions/ios.js | 139 ++++--- functions/package.json | 2 +- .../test/fixtures/live-activity/end.json | 15 +- .../live-activity/start-push-to-start.json | 15 +- .../fixtures/live-activity/update-basic.json | 13 +- .../fixtures/live-activity/update-full.json | 22 +- functions/test/ios.test.js | 387 ++++++++++-------- functions/webapp.js | 8 +- 11 files changed, 333 insertions(+), 535 deletions(-) delete mode 100644 functions/apns.js diff --git a/functions/apns.js b/functions/apns.js deleted file mode 100644 index 7402e7b..0000000 --- a/functions/apns.js +++ /dev/null @@ -1,148 +0,0 @@ -'use strict'; - -// Direct APNs HTTP/2 client used for iOS Live Activity push notifications. -// -// Live Activity push tokens are hex-encoded direct APNs tokens — they are NOT FCM tokens. -// FCM tokens always contain a colon and are routed through Firebase; Live Activity tokens -// bypass Firebase entirely and must be delivered directly to api.push.apple.com via HTTP/2. -// Additionally, FCM does not support apns-push-type: liveactivity, so this path is required. -// -// Required environment variables: -// APNS_TEAM_ID - 10-character Apple Developer Team ID -// APNS_KEY_ID - 10-character key ID for the .p8 signing key -// APNS_PRIVATE_KEY - Contents of the .p8 file (newlines may be escaped as \n) - -const crypto = require('crypto'); -const http2 = require('http2'); - -const APNS_HOST_PRODUCTION = 'api.push.apple.com'; -const APNS_HOST_SANDBOX = 'api.sandbox.push.apple.com'; - -// JWT is valid for up to 60 minutes; rotate at 45 to stay well within that window. -const JWT_ROTATION_MS = 45 * 60 * 1000; - -let jwtCache = null; -let jwtGeneratedAt = 0; - -// Cached HTTP/2 sessions keyed by environment. -const sessions = {}; - -function getApnsHost(environment) { - return environment === 'sandbox' ? APNS_HOST_SANDBOX : APNS_HOST_PRODUCTION; -} - -function generateJWT() { - if (jwtCache && Date.now() - jwtGeneratedAt < JWT_ROTATION_MS) { - return jwtCache; - } - - const teamId = process.env.APNS_TEAM_ID; - const keyId = process.env.APNS_KEY_ID; - const privateKey = (process.env.APNS_PRIVATE_KEY || '').replace(/\\n/g, '\n'); - - if (!teamId || !keyId || !privateKey) { - throw new Error('Missing APNs credentials: APNS_TEAM_ID, APNS_KEY_ID, APNS_PRIVATE_KEY required'); - } - - const now = Math.floor(Date.now() / 1000); - const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: keyId })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ iss: teamId, iat: now })).toString('base64url'); - const signingInput = `${header}.${payload}`; - - const sign = crypto.createSign('SHA256'); - sign.update(signingInput); - // ieee-p1363 produces the raw r||s format required for JWT ES256 (not DER-encoded). - const signature = sign.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); - - jwtCache = `${signingInput}.${signature}`; - jwtGeneratedAt = Date.now(); - - return jwtCache; -} - -// Returns a cached HTTP/2 session for the given APNs environment. -// Sessions are cached at module level so warm Cloud Function instances reuse the same -// persistent connection rather than re-establishing a TLS handshake on every request. -// Sandbox and production use separate sessions because they connect to different hosts; -// APNs will reject a production token sent to the sandbox endpoint and vice versa. -function getSession(environment) { - const existing = sessions[environment]; - if (existing && !existing.destroyed && !existing.closed) { - return existing; - } - - const host = getApnsHost(environment); - const session = http2.connect(`https://${host}`); - session.on('error', () => { - // Clean up the cached reference so the next call creates a fresh session. - session.destroy(); - delete sessions[environment]; - }); - sessions[environment] = session; - return session; -} - -/** - * Sends a payload directly to the APNs HTTP/2 API. - * - * @param {string} token - Hex-encoded APNs push token. - * @param {object} payload - APNs JSON payload object. - * @param {object} extraHeaders - Platform-specific APNs headers (apns-push-type, apns-topic, etc.). - * @param {string} environment - 'sandbox' or 'production'. - * @returns {Promise<{ apnsId: string, status: number, body: object }>} - */ -async function send(token, payload, extraHeaders, environment) { - const jwt = generateJWT(); - const host = getApnsHost(environment); - const session = getSession(environment); - const body = JSON.stringify(payload); - - const reqHeaders = { - ':method': 'POST', - ':path': `/3/device/${token}`, - ':scheme': 'https', - ':authority': host, - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(body), - authorization: `bearer ${jwt}`, - ...extraHeaders, - }; - - return new Promise((resolve, reject) => { - const req = session.request(reqHeaders); - - let responseHeaders = {}; - let responseBody = ''; - - req.on('response', (headers) => { - responseHeaders = headers; - }); - - req.on('data', (chunk) => { - responseBody += chunk; - }); - - req.on('end', () => { - const status = responseHeaders[':status']; - let parsedBody; - if (!responseBody) { - parsedBody = {}; - } else { - try { - parsedBody = JSON.parse(responseBody); - } catch (e) { - // APNs or an intermediary returned a non-JSON body; preserve the raw text. - parsedBody = { raw: responseBody }; - } - } - resolve({ status, apnsId: responseHeaders['apns-id'] ?? null, body: parsedBody }); - }); - - req.on('error', reject); - - req.write(body); - req.end(); - }); -} - -module.exports = { send }; diff --git a/functions/handlers.js b/functions/handlers.js index 90c5b35..adf4677 100644 --- a/functions/handlers.js +++ b/functions/handlers.js @@ -3,7 +3,6 @@ const { Logging } = require('@google-cloud/logging'); const { getMessaging } = require('firebase-admin/messaging'); const { FirestoreRateLimiter, ValkeyRateLimiter } = require('./rate-limiter'); -const apns = require('./apns'); const MAX_NOTIFICATIONS_PER_DAY = parseInt(process.env.MAX_NOTIFICATIONS_PER_DAY || '500'); const REGION = (process.env.REGION || 'us-central1').toLowerCase(); @@ -347,116 +346,5 @@ function buildLogMetadata(req) { }; } -// APNs push tokens are hex-encoded device tokens and never contain a colon. -// handleRequest uses a colon check to distinguish FCM tokens from legacy SNS tokens; -// we use a hex-only check here because Live Activity tokens are direct APNs tokens. -// APNs device tokens are fixed-length: 32 bytes, hex-encoded as 64 characters. -function isValidApnsToken(token) { - return typeof token === 'string' && token.length === 64 && /^[0-9a-fA-F]+$/.test(token); -} - -// Handles Live Activity push requests delivered directly to APNs via HTTP/2. -// -// This is intentionally separate from handleRequest for two reasons: -// 1. Token format: Live Activity tokens are hex APNs tokens, not FCM tokens with a colon. -// 2. Delivery path: Live Activities require apns-push-type: liveactivity, which FCM does -// not support. The payload goes straight to APNs via apns.js, not through messaging.send(). -async function handleLiveActivityRequest(req, res, payloadHandler) { - const log = logging.log('handleLiveActivityRequest'); - const metadata = buildLogMetadata(req); - - if (debug) { - log.debug(log.entry(metadata, { message: 'Handling live activity request' })); - } - - const { push_token: token } = req.body; - if (!token) { - return res.status(403).send({ errorMessage: 'You did not send a token!' }); - } - if (!isValidApnsToken(token)) { - return res.status(403).send({ errorMessage: 'That is not a valid APNs token' }); - } - - const { updateRateLimits, apnsPayload, apnsHeaders, apnsEnvironment } = payloadHandler(req); - - let rateLimitInfo; - try { - rateLimitInfo = await rateLimiter.checkRateLimit(token); - } catch (err) { - return handleError(req, res, apnsPayload, 'getRateLimitDoc', err); - } - - if (updateRateLimits) { - const attemptInfo = await rateLimiter.recordAttempt(token); - - if (attemptInfo.isRateLimited) { - return res.status(429).send({ - errorType: 'RateLimited', - message: - 'The given target has reached the maximum number of notifications allowed per day. Please try again later.', - target: token, - rateLimits: attemptInfo.rateLimits, - }); - } - } - - if (debug) { - log.info( - log.entry(metadata, { - message: 'Sending live activity notification', - notification: JSON.stringify(apnsPayload), - }), - ); - } - - let apnsId; - let rateLimits; - try { - const result = await apns.send(token, apnsPayload, apnsHeaders, apnsEnvironment); - if (result.status !== 200) { - const reason = result.body?.reason ?? 'Unknown'; - const err = new Error(`APNs error: ${reason} (HTTP ${result.status})`); - // BadDeviceToken means the token is invalid or the activity has already ended. - // This is expected (not a server bug), so we return an InvalidToken response - // without logging to Cloud Error Reporting — same pattern as handleRequest uses - // for FCM's invalid-registration-token error code. - if (result.status === 400 && reason === 'BadDeviceToken') { - if (updateRateLimits) { - await rateLimiter.recordError(token); - } - return res.status(500).send({ - errorType: 'InvalidToken', - errorStep: 'sendNotification', - message: err.message, - }); - } - throw err; - } - apnsId = result.apnsId; - if (updateRateLimits) { - rateLimits = await rateLimiter.recordSuccess(token); - } else { - rateLimits = rateLimitInfo.rateLimits; - } - } catch (err) { - if (updateRateLimits) { - await rateLimiter.recordError(token); - } - return handleError(req, res, apnsPayload, 'sendNotification', err); - } - - if (debug) { - log.info(log.entry(metadata, { message: 'Successfully sent live activity notification', apnsId })); - } - - return res.status(201).send({ - messageId: apnsId, - sentPayload: apnsPayload, - target: token, - rateLimits: rateLimits, - }); -} - exports.handleRequest = handleRequest; exports.handleCheckRateLimits = handleCheckRateLimits; -exports.handleLiveActivityRequest = handleLiveActivityRequest; diff --git a/functions/index.js b/functions/index.js index 0980cfc..2bb8cad 100644 --- a/functions/index.js +++ b/functions/index.js @@ -17,7 +17,7 @@ const regionalFunctions = functions.region(region).runWith({ timeoutSeconds: 10 process.env.DEBUG = isDebug().toString(); process.env.REGION = region; -const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = require('./handlers'); +const { handleRequest, handleCheckRateLimits } = require('./handlers'); exports.androidV1 = regionalFunctions.https.onRequest(async (req, res) => handleRequest(req, res, android.createPayload), @@ -31,10 +31,6 @@ exports.sendPushNotification = regionalFunctions.https.onRequest(async (req, res handleRequest(req, res, legacy.createPayload), ); -exports.iOSLiveActivityV1 = regionalFunctions.https.onRequest(async (req, res) => - handleLiveActivityRequest(req, res, ios.createLiveActivityPayload), -); - exports.checkRateLimits = regionalFunctions.https.onRequest(async (req, res) => handleCheckRateLimits(req, res), ); @@ -49,4 +45,3 @@ function isDebug() { exports.handleRequest = handleRequest; exports.handleCheckRateLimits = handleCheckRateLimits; -exports.handleLiveActivityRequest = handleLiveActivityRequest; diff --git a/functions/ios.js b/functions/ios.js index 0fa2dc1..4d0e7b6 100644 --- a/functions/ios.js +++ b/functions/ios.js @@ -6,6 +6,15 @@ const NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS = new Set(['end']); module.exports = { createPayload: (req) => { + // When live_activity_token is present, this is a Live Activity push notification. + // Firebase Admin SDK v13.5.0+ supports the liveActivityToken (camelCase) field in the + // apns config object. When set, FCM automatically adds apns-push-type: liveactivity + // and routes the notification to APNs correctly. No APNs credentials, HTTP/2 sessions, + // or environment routing are needed — FCM handles it all. + if (req.body.live_activity_token) { + return buildLiveActivityPayload(req); + } + const payload = { notification: { body: req.body.message, @@ -147,79 +156,83 @@ module.exports = { return { updateRateLimits, payload }; }, +}; - // Builds the APNs payload for a Live Activity push notification. - // - // This returns a different shape than createPayload because Live Activities bypass FCM - // entirely — the payload is delivered directly to APNs via apns.js. The returned object - // contains apnsPayload/apnsHeaders/apnsEnvironment instead of an FCM message object. - createLiveActivityPayload: (req) => { - const { data = {} } = req.body; - const event = data.event ?? 'update'; - const now = Math.floor(Date.now() / 1000); - - const aps = { - timestamp: now, - event, +// Builds an FCM-compatible payload for Live Activity push notifications. +// +// The liveActivityToken field (camelCase) is required by Firebase Admin SDK v13.5.0+. +// When present in the apns config, FCM automatically sets apns-push-type: liveactivity +// and routes the notification to APNs correctly. No APNs credentials, HTTP/2 sessions, +// or environment routing are needed — FCM handles it all. +function buildLiveActivityPayload(req) { + const { data = {} } = req.body; + const event = data.event ?? 'update'; + const now = Math.floor(Date.now() / 1000); + + const aps = { + timestamp: now, + event, + }; + + // content-state is required for start and update; send for end as well so the + // activity can display final state before dismissal. + aps['content-state'] = buildLiveActivityContentState(req.body, data); + + if (event === 'start') { + // Push-to-start requires the static attributes that were registered with the activity. + // 'attributes-type' must exactly match the Swift struct name — HALiveActivityAttributes — + // because APNs uses it to look up the registered ActivityKit type on the device. + // This value is case-sensitive and cannot change after the app has shipped. + aps['attributes-type'] = 'HALiveActivityAttributes'; + aps.attributes = { + tag: data.activity_id ?? data.tag ?? '', + title: req.body.title ?? '', }; + } - // content-state is required for start and update; send for end as well so the - // activity can display final state before dismissal. - aps['content-state'] = buildLiveActivityContentState(req.body, data); - - if (event === 'start') { - // Push-to-start requires the static attributes that were registered with the activity. - // 'attributes-type' must exactly match the Swift struct name — HALiveActivityAttributes — - // because APNs uses it to look up the registered ActivityKit type on the device. - // This value is case-sensitive and cannot change after the app has shipped. - aps['attributes-type'] = 'HALiveActivityAttributes'; - aps.attributes = { - tag: data.activity_id ?? data.tag ?? '', - title: req.body.title ?? '', - }; - } - - if (event === 'end' && data.dismissal_date) { - aps['dismissal-date'] = data.dismissal_date; - } + if (event === 'end' && data.dismissal_date) { + aps['dismissal-date'] = data.dismissal_date; + } - if (data.stale_date) { - aps['stale-date'] = data.stale_date; - } + if (data.stale_date) { + aps['stale-date'] = data.stale_date; + } - if (data.relevance_score !== undefined) { - aps['relevance-score'] = data.relevance_score; - } + if (data.relevance_score !== undefined) { + aps['relevance-score'] = data.relevance_score; + } - // Optional alert shown alongside the live activity update. - if (data.alert) { - aps.alert = data.alert; - if (data.alert_sound) { - aps.sound = data.alert_sound; - } + // Optional alert shown alongside the live activity update. + if (data.alert) { + aps.alert = data.alert; + if (data.alert_sound) { + aps.sound = data.alert_sound; } + } - // Sandbox tokens are rejected by the production APNs endpoint and vice versa. - // The client reports its environment during registration so we can route correctly. - // Normalize to the two valid values so unexpected strings don't create unbounded - // session cache entries in apns.js (e.g. 'Production', 'prod', or typos). - const rawEnvironment = req.body.registration_info?.apns_environment; - const apnsEnvironment = rawEnvironment === 'sandbox' ? 'sandbox' : 'production'; - const bundleId = req.body.registration_info?.app_id ?? 'io.robbie.HomeAssistant'; - - return { - updateRateLimits: !NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS.has(event), - apnsPayload: { aps }, - apnsHeaders: { - 'apns-push-type': 'liveactivity', - // APNs requires the topic to include the push-type suffix for Live Activities. - 'apns-topic': `${bundleId}.push-type.liveactivity`, + const payload = { + apns: { + // The liveActivityToken (camelCase) tells Firebase Admin SDK v13.5.0+ to route + // this message as a Live Activity notification. FCM automatically sets the + // apns-push-type: liveactivity header and the correct apns-topic suffix. + liveActivityToken: req.body.live_activity_token, + headers: { 'apns-priority': '10', }, - apnsEnvironment, - }; - }, -}; + payload: { + aps, + }, + }, + fcm_options: { + analytics_label: 'iOSLiveActivityV1', + }, + }; + + return { + updateRateLimits: !NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS.has(event), + payload, + }; +} // Builds the content-state object that APNs delivers to the app's Live Activity widget. // Each field maps to a property in the Swift HALiveActivityContentState Codable struct. diff --git a/functions/package.json b/functions/package.json index c148e9a..1ce665c 100644 --- a/functions/package.json +++ b/functions/package.json @@ -18,7 +18,7 @@ "@valkey/valkey-glide": "^2.0.1", "fastify": "^5.8.3", "firebase-admin": "^13.5.0", - "firebase-functions": "^6.1.1" +"firebase-functions": "^6.1.1" }, "devDependencies": { "@types/node": "^24.1.0", diff --git a/functions/test/fixtures/live-activity/end.json b/functions/test/fixtures/live-activity/end.json index e0f773d..f3496b5 100644 --- a/functions/test/fixtures/live-activity/end.json +++ b/functions/test/fixtures/live-activity/end.json @@ -1,13 +1,13 @@ { "input": { - "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "message": "Washer cycle complete", "title": "Laundry", "registration_info": { "app_id": "io.robbie.HomeAssistant", "app_version": "2024.1", - "os_version": "17.0", - "apns_environment": "sandbox" + "os_version": "17.0" }, "data": { "event": "end", @@ -21,7 +21,12 @@ }, "expected": { "updateRateLimits": false, - "apnsEnvironment": "sandbox", - "apsEvent": "end" + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "apsEvent": "end", + "contentState": { + "message": "Washer cycle complete", + "icon": "mdi:washing-machine-off" + }, + "dismissalDate": 1234571490 } } diff --git a/functions/test/fixtures/live-activity/start-push-to-start.json b/functions/test/fixtures/live-activity/start-push-to-start.json index 88e8002..05794d3 100644 --- a/functions/test/fixtures/live-activity/start-push-to-start.json +++ b/functions/test/fixtures/live-activity/start-push-to-start.json @@ -1,13 +1,13 @@ { "input": { - "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "message": "Laundry started", "title": "Laundry", "registration_info": { "app_id": "io.robbie.HomeAssistant", "app_version": "2024.1", - "os_version": "17.2", - "apns_environment": "sandbox" + "os_version": "17.2" }, "data": { "event": "start", @@ -23,12 +23,19 @@ }, "expected": { "updateRateLimits": true, - "apnsEnvironment": "sandbox", + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "apsEvent": "start", "attributesType": "HALiveActivityAttributes", "attributes": { "tag": "laundry-001", "title": "Laundry" + }, + "contentState": { + "message": "Laundry started", + "progress": 0, + "progress_max": 3600, + "icon": "mdi:washing-machine", + "color": "#2196F3" } } } diff --git a/functions/test/fixtures/live-activity/update-basic.json b/functions/test/fixtures/live-activity/update-basic.json index 0f80de3..7d6fb25 100644 --- a/functions/test/fixtures/live-activity/update-basic.json +++ b/functions/test/fixtures/live-activity/update-basic.json @@ -1,13 +1,13 @@ { "input": { - "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "message": "Washer is done", "title": "Laundry", "registration_info": { "app_id": "io.robbie.HomeAssistant", "app_version": "2024.1", - "os_version": "17.0", - "apns_environment": "sandbox" + "os_version": "17.0" }, "data": { "event": "update", @@ -23,12 +23,7 @@ }, "expected": { "updateRateLimits": true, - "apnsEnvironment": "sandbox", - "apnsHeaders": { - "apns-push-type": "liveactivity", - "apns-topic": "io.robbie.HomeAssistant.push-type.liveactivity", - "apns-priority": "10" - }, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "apsEvent": "update", "contentState": { "message": "Washer is done", diff --git a/functions/test/fixtures/live-activity/update-full.json b/functions/test/fixtures/live-activity/update-full.json index 33efd38..49ca9b6 100644 --- a/functions/test/fixtures/live-activity/update-full.json +++ b/functions/test/fixtures/live-activity/update-full.json @@ -1,13 +1,13 @@ { "input": { - "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "push_token": "test:fcm-token-123", + "live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "message": "Timer running", "title": "Kitchen Timer", "registration_info": { "app_id": "io.robbie.HomeAssistant", "app_version": "2024.1", - "os_version": "17.0", - "apns_environment": "production" + "os_version": "17.0" }, "data": { "event": "update", @@ -33,12 +33,7 @@ }, "expected": { "updateRateLimits": true, - "apnsEnvironment": "production", - "apnsHeaders": { - "apns-push-type": "liveactivity", - "apns-topic": "io.robbie.HomeAssistant.push-type.liveactivity", - "apns-priority": "10" - }, + "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "apsEvent": "update", "contentState": { "message": "45 min remaining", @@ -49,6 +44,13 @@ "countdown_end": "2024-01-01T12:00:00Z", "icon": "mdi:timer", "color": "#FF5722" - } + }, + "staleDate": 1234571490, + "relevanceScore": 0.8, + "alert": { + "title": "Timer Update", + "body": "45 minutes remaining" + }, + "alertSound": "default" } } diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js index eb3b296..2104ef4 100644 --- a/functions/test/ios.test.js +++ b/functions/test/ios.test.js @@ -8,8 +8,7 @@ const { assertResponse } = require('./utils/assertion-helpers'); // --- Mocks --- -const mockApns = { send: jest.fn() }; -jest.mock('../apns', () => mockApns); +const mockMessaging = { send: jest.fn() }; const mockFirestore = { collection: jest.fn(), runTransaction: jest.fn() }; const mockLogging = { @@ -28,7 +27,7 @@ jest.mock('firebase-admin/firestore', () => ({ Timestamp: { fromDate: jest.fn(() => 'mock-timestamp') }, })); jest.mock('firebase-admin/messaging', () => ({ - getMessaging: jest.fn(() => ({ send: jest.fn() })), + getMessaging: jest.fn(() => mockMessaging), })); jest.mock('firebase-functions', () => ({ config: jest.fn(() => ({})), @@ -37,26 +36,25 @@ jest.mock('firebase-functions', () => ({ https: { onRequest: jest.fn() }, })); -const { handleLiveActivityRequest } = require('../index.js'); +const { handleRequest } = require('../index.js'); const ios = require('../ios'); // --- Helpers --- -const VALID_APNS_TOKEN = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; +const FCM_TOKEN = 'test:fcm-token-123'; +const LIVE_ACTIVITY_TOKEN = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; -// Pass body field overrides directly (not nested under body: {}) to avoid -// createMockRequest's outer Object.assign overwriting the merged body. function createLiveActivityRequest(bodyOverrides = {}) { return createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, message: 'Test message', title: 'Test title', registration_info: { app_id: 'io.robbie.HomeAssistant', app_version: '2024.1', os_version: '17.0', - apns_environment: 'sandbox', }, data: { event: 'update', @@ -101,240 +99,289 @@ function setupFirestoreMocks() { return { docRef, docSnapshot }; } -// --- createPayload tests (fixture-driven) --- +// --- Fixture-driven tests --- const fixturesDir = path.join(__dirname, 'fixtures/live-activity'); const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.json')); -describe('live-activity createPayload', () => { +describe('live-activity createPayload via FCM', () => { + // Fixture-driven tests: load each fixture, call ios.createPayload with + // live_activity_token in the body, assert the returned payload has + // apns.liveActivityToken and correct aps fields. it.each(fixtureFiles)('%s', (filename) => { const fixture = JSON.parse(fs.readFileSync(path.join(fixturesDir, filename), 'utf8')); const req = createMockRequest({ body: fixture.input }); - const result = ios.createLiveActivityPayload(req); + const result = ios.createPayload(req); expect(result.updateRateLimits).toBe(fixture.expected.updateRateLimits); - expect(result.apnsEnvironment).toBe(fixture.expected.apnsEnvironment); - if (fixture.expected.apnsHeaders) { - expect(result.apnsHeaders).toMatchObject(fixture.expected.apnsHeaders); - } + // liveActivityToken should be set from the input + expect(result.payload.apns.liveActivityToken).toBe(fixture.expected.liveActivityToken); + + // apns-priority header should be '10' + expect(result.payload.apns.headers['apns-priority']).toBe('10'); + + // No apns-push-type or apns-topic headers — FCM sets them automatically + expect(result.payload.apns.headers['apns-push-type']).toBeUndefined(); + expect(result.payload.apns.headers['apns-topic']).toBeUndefined(); - expect(result.apnsPayload.aps.event).toBe(fixture.expected.apsEvent); - expect(typeof result.apnsPayload.aps.timestamp).toBe('number'); + // Check aps fields + const aps = result.payload.apns.payload.aps; + expect(aps.event).toBe(fixture.expected.apsEvent); + expect(typeof aps.timestamp).toBe('number'); if (fixture.expected.contentState) { - expect(result.apnsPayload.aps['content-state']).toMatchObject(fixture.expected.contentState); + expect(aps['content-state']).toMatchObject(fixture.expected.contentState); } if (fixture.expected.attributesType) { - expect(result.apnsPayload.aps['attributes-type']).toBe(fixture.expected.attributesType); + expect(aps['attributes-type']).toBe(fixture.expected.attributesType); } if (fixture.expected.attributes) { - expect(result.apnsPayload.aps.attributes).toMatchObject(fixture.expected.attributes); + expect(aps.attributes).toMatchObject(fixture.expected.attributes); } - }); -}); - -// --- handleLiveActivityRequest integration tests --- - -describe('handleLiveActivityRequest', () => { - let res; - - beforeEach(() => { - jest.clearAllMocks(); - mockApns.send.mockResolvedValue({ status: 200, apnsId: 'mock-apns-id', body: {} }); - res = createMockResponse(); - setupFirestoreMocks(); - }); - - test('sends successfully and returns 201', async () => { - const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - expect(mockApns.send).toHaveBeenCalledTimes(1); - const [sentToken, sentPayload, sentHeaders, sentEnv] = mockApns.send.mock.calls[0]; - expect(sentToken).toBe(VALID_APNS_TOKEN); - expect(sentPayload.aps.event).toBe('update'); - expect(sentHeaders['apns-push-type']).toBe('liveactivity'); - expect(sentEnv).toBe('sandbox'); - assertResponse.expectSuccessResponse(res); - const response = res.send.mock.calls[0][0]; - expect(response.messageId).toBe('mock-apns-id'); - expect(response.target).toBe(VALID_APNS_TOKEN); - expect(response.rateLimits).toBeDefined(); - }); - - test('rejects missing token with 403', async () => { - const req = createLiveActivityRequest({ push_token: undefined }); - delete req.body.push_token; - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectForbiddenResponse(res, 'You did not send a token!'); - expect(mockApns.send).not.toHaveBeenCalled(); - }); - - test('rejects FCM token (contains colon) with 403', async () => { - const req = createLiveActivityRequest({ push_token: 'fcm:token123' }); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); - expect(mockApns.send).not.toHaveBeenCalled(); - }); - - test('rejects non-hex token with 403', async () => { - const req = createLiveActivityRequest({ push_token: 'not-a-valid-token!!' }); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); - expect(mockApns.send).not.toHaveBeenCalled(); - }); - - test('rejects hex token with wrong length with 403', async () => { - // APNs tokens are exactly 64 hex chars (32 bytes); shorter values are invalid. - const req = createLiveActivityRequest({ push_token: 'a1b2c3d4' }); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectForbiddenResponse(res, 'That is not a valid APNs token'); - expect(mockApns.send).not.toHaveBeenCalled(); - }); - - test('does not update rate limits for end events', async () => { - const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - expect(mockApns.send).toHaveBeenCalledTimes(1); - assertResponse.expectSuccessResponse(res); - const response = res.send.mock.calls[0][0]; - // Rate limits should still be present (from checkRateLimit), just not incremented - expect(response.rateLimits).toBeDefined(); - }); - - test('returns 500 InvalidToken on APNs BadDeviceToken', async () => { - mockApns.send.mockResolvedValue({ status: 400, apnsId: null, body: { reason: 'BadDeviceToken' } }); - const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectErrorResponse(res, 500, { errorType: 'InvalidToken' }); - }); - - test('returns 500 InternalError on APNs send failure', async () => { - mockApns.send.mockRejectedValue(new Error('Network error')); - const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); - - assertResponse.expectErrorResponse(res, 500, { - errorType: 'InternalError', - errorStep: 'sendNotification', - }); - }); + if (fixture.expected.dismissalDate) { + expect(aps['dismissal-date']).toBe(fixture.expected.dismissalDate); + } - test('returns 500 InternalError on unexpected APNs status', async () => { - mockApns.send.mockResolvedValue({ status: 500, apnsId: null, body: { reason: 'InternalServerError' } }); - const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); + if (fixture.expected.staleDate) { + expect(aps['stale-date']).toBe(fixture.expected.staleDate); + } - assertResponse.expectErrorResponse(res, 500, { errorType: 'InternalError' }); - }); + if (fixture.expected.relevanceScore) { + expect(aps['relevance-score']).toBe(fixture.expected.relevanceScore); + } - test('returns 429 when rate limited', async () => { - const { docSnapshot } = setupFirestoreMocks(); - docSnapshot.exists = true; - docSnapshot.data.mockReturnValue( - createMockRateLimitData({ attemptsCount: 501, deliveredCount: 501, totalCount: 501 }), - ); + if (fixture.expected.alert) { + expect(aps.alert).toMatchObject(fixture.expected.alert); + } - const req = createLiveActivityRequest(); - await handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); + if (fixture.expected.alertSound) { + expect(aps.sound).toBe(fixture.expected.alertSound); + } - assertResponse.expectRateLimitResponse(res, VALID_APNS_TOKEN); - expect(mockApns.send).not.toHaveBeenCalled(); + // analytics_label should be set for Live Activity + expect(result.payload.fcm_options.analytics_label).toBe('iOSLiveActivityV1'); }); -}); -// --- createPayload unit tests --- + // --- Unit tests for the FCM payload builder --- -describe('live-activity createPayload unit', () => { test('defaults event to update when not specified', () => { const req = createLiveActivityRequest({ data: {} }); - const { apnsPayload } = ios.createLiveActivityPayload(req); - expect(apnsPayload.aps.event).toBe('update'); - }); - - test('defaults apnsEnvironment to production when not specified', () => { - const req = createMockRequest({ - body: { - push_token: VALID_APNS_TOKEN, - registration_info: { app_id: 'io.robbie.HomeAssistant' }, - data: { event: 'update' }, - }, - }); - const { apnsEnvironment } = ios.createLiveActivityPayload(req); - expect(apnsEnvironment).toBe('production'); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps.event).toBe('update'); }); test('start event includes attributes-type and attributes', () => { const req = createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, title: 'Laundry', - registration_info: { app_id: 'io.robbie.HomeAssistant', apns_environment: 'sandbox' }, + registration_info: { app_id: 'io.robbie.HomeAssistant' }, data: { event: 'start', activity_id: 'laundry-001' }, }, }); - const { apnsPayload } = ios.createLiveActivityPayload(req); - expect(apnsPayload.aps['attributes-type']).toBe('HALiveActivityAttributes'); - expect(apnsPayload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps['attributes-type']).toBe('HALiveActivityAttributes'); + expect(payload.apns.payload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); }); test('end event includes dismissal-date when provided', () => { const req = createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, registration_info: { app_id: 'io.robbie.HomeAssistant' }, data: { event: 'end', dismissal_date: 9999999 }, }, }); - const { apnsPayload } = ios.createLiveActivityPayload(req); - expect(apnsPayload.aps['dismissal-date']).toBe(9999999); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps['dismissal-date']).toBe(9999999); }); test('stale-date and relevance-score are included when provided', () => { const req = createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, registration_info: { app_id: 'io.robbie.HomeAssistant' }, data: { event: 'update', stale_date: 1111, relevance_score: 0.5 }, }, }); - const { apnsPayload } = ios.createLiveActivityPayload(req); - expect(apnsPayload.aps['stale-date']).toBe(1111); - expect(apnsPayload.aps['relevance-score']).toBe(0.5); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps['stale-date']).toBe(1111); + expect(payload.apns.payload.aps['relevance-score']).toBe(0.5); }); - test('apns-topic uses bundle id from registration_info', () => { + test('content-state maps fields correctly', () => { const req = createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, - registration_info: { app_id: 'com.example.app', apns_environment: 'production' }, - data: { event: 'update' }, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, + message: 'Fallback', + registration_info: { app_id: 'io.robbie.HomeAssistant' }, + data: { + event: 'update', + content_state: { + message: 'Override', + critical_text: 'Critical', + progress: 50, + progress_max: 100, + chronometer: true, + countdown_end: '2024-01-01T00:00:00Z', + icon: 'mdi:test', + color: '#FF0000', + }, + }, }, }); - const { apnsHeaders } = ios.createLiveActivityPayload(req); - expect(apnsHeaders['apns-topic']).toBe('com.example.app.push-type.liveactivity'); + const { payload } = ios.createPayload(req); + const cs = payload.apns.payload.aps['content-state']; + expect(cs.message).toBe('Override'); + expect(cs.critical_text).toBe('Critical'); + expect(cs.progress).toBe(50); + expect(cs.progress_max).toBe(100); + expect(cs.chronometer).toBe(true); + expect(cs.countdown_end).toBe('2024-01-01T00:00:00Z'); + expect(cs.icon).toBe('mdi:test'); + expect(cs.color).toBe('#FF0000'); }); test('top-level message is used when no content_state', () => { const req = createMockRequest({ body: { - push_token: VALID_APNS_TOKEN, + push_token: FCM_TOKEN, + live_activity_token: LIVE_ACTIVITY_TOKEN, message: 'Hello from HA', registration_info: { app_id: 'io.robbie.HomeAssistant' }, data: { event: 'update' }, }, }); - const { apnsPayload } = ios.createLiveActivityPayload(req); - expect(apnsPayload.aps['content-state'].message).toBe('Hello from HA'); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps['content-state'].message).toBe('Hello from HA'); + }); + + test('liveActivityToken is set from req.body.live_activity_token', () => { + const req = createLiveActivityRequest(); + const { payload } = ios.createPayload(req); + expect(payload.apns.liveActivityToken).toBe(LIVE_ACTIVITY_TOKEN); + }); + + test('apns-priority header is set to 10', () => { + const req = createLiveActivityRequest(); + const { payload } = ios.createPayload(req); + expect(payload.apns.headers['apns-priority']).toBe('10'); + }); + + test('no apns-push-type or apns-topic headers (FCM sets them)', () => { + const req = createLiveActivityRequest(); + const { payload } = ios.createPayload(req); + expect(payload.apns.headers['apns-push-type']).toBeUndefined(); + expect(payload.apns.headers['apns-topic']).toBeUndefined(); + }); + + test('end events do not update rate limits', () => { + const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); + const result = ios.createPayload(req); + expect(result.updateRateLimits).toBe(false); + }); + + test('normal notifications (no live_activity_token) still work as before', () => { + const req = createMockRequest({ + body: { + push_token: FCM_TOKEN, + message: 'Hello', + title: 'Test', + registration_info: { + app_id: 'io.robbie.HomeAssistant', + app_version: '2024.1', + os_version: '17.0', + }, + }, + }); + const result = ios.createPayload(req); + // Normal notification should have notification object, not liveActivityToken + expect(result.payload.notification).toBeDefined(); + expect(result.payload.notification.body).toBe('Hello'); + expect(result.payload.apns.liveActivityToken).toBeUndefined(); + expect(result.payload.fcm_options.analytics_label).toBe('iosV1Notification'); + }); +}); + +// --- handleRequest integration tests for Live Activity --- + +describe('handleRequest with Live Activity payload', () => { + let res; + + beforeEach(() => { + jest.clearAllMocks(); + mockMessaging.send.mockResolvedValue('mock-message-id'); + res = createMockResponse(); + setupFirestoreMocks(); + }); + + test('sends Live Activity via FCM and returns 201', async () => { + const req = createLiveActivityRequest(); + await handleRequest(req, res, ios.createPayload); + + expect(mockMessaging.send).toHaveBeenCalledTimes(1); + const sentPayload = mockMessaging.send.mock.calls[0][0]; + expect(sentPayload.apns.liveActivityToken).toBe(LIVE_ACTIVITY_TOKEN); + expect(sentPayload.apns.payload.aps.event).toBe('update'); + expect(sentPayload.apns.headers['apns-priority']).toBe('10'); + expect(sentPayload.token).toBe(FCM_TOKEN); + + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + expect(response.messageId).toBe('mock-message-id'); + expect(response.target).toBe(FCM_TOKEN); + expect(response.rateLimits).toBeDefined(); + }); + + test('rejects missing token with 403', async () => { + const req = createLiveActivityRequest({ push_token: undefined }); + delete req.body.push_token; + await handleRequest(req, res, ios.createPayload); + + assertResponse.expectForbiddenResponse(res, 'You did not send a token!'); + expect(mockMessaging.send).not.toHaveBeenCalled(); + }); + + test('does not update rate limits for end events', async () => { + const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); + await handleRequest(req, res, ios.createPayload); + + expect(mockMessaging.send).toHaveBeenCalledTimes(1); + assertResponse.expectSuccessResponse(res); + const response = res.send.mock.calls[0][0]; + expect(response.rateLimits).toBeDefined(); + }); + + test('returns 500 on FCM send failure', async () => { + mockMessaging.send.mockRejectedValue(new Error('Network error')); + const req = createLiveActivityRequest(); + await handleRequest(req, res, ios.createPayload); + + assertResponse.expectErrorResponse(res, 500, { + errorType: 'InternalError', + errorStep: 'sendNotification', + }); + }); + + test('returns 429 when rate limited', async () => { + const { docSnapshot } = setupFirestoreMocks(); + docSnapshot.exists = true; + docSnapshot.data.mockReturnValue( + createMockRateLimitData({ attemptsCount: 501, deliveredCount: 501, totalCount: 501 }), + ); + + const req = createLiveActivityRequest(); + await handleRequest(req, res, ios.createPayload); + + assertResponse.expectRateLimitResponse(res, FCM_TOKEN); + expect(mockMessaging.send).not.toHaveBeenCalled(); }); }); diff --git a/functions/webapp.js b/functions/webapp.js index 1e0d786..ee5aa73 100644 --- a/functions/webapp.js +++ b/functions/webapp.js @@ -8,7 +8,7 @@ const { loggerConfig } = require('./fastify-logger'); const fastify = require('fastify')({ logger: loggerConfig, trustProxy: true }); // Import the handlers -const { handleRequest, handleCheckRateLimits, handleLiveActivityRequest } = require('./handlers'); +const { handleRequest, handleCheckRateLimits } = require('./handlers'); const android = require('./android'); const ios = require('./ios'); @@ -48,11 +48,6 @@ async function handleSendPushNotification(request, reply) { return handleRequest(req, res, legacy.createPayload); } -async function handleIOSLiveActivityV1(request, reply) { - const { req, res } = createCloudFunctionsAdapter(request, reply); - return handleLiveActivityRequest(req, res, ios.createLiveActivityPayload); -} - async function checkRateLimits(request, reply) { const { req, res } = createCloudFunctionsAdapter(request, reply); return handleCheckRateLimits(req, res); @@ -61,7 +56,6 @@ async function checkRateLimits(request, reply) { // Register routes fastify.post('/api/sendPush/android/v1', handleAndroidV1); fastify.post('/api/sendPush/iOS/v1', handleIOSV1); -fastify.post('/api/sendPush/iOS/liveActivity/v1', handleIOSLiveActivityV1); fastify.post('/api/sendPushNotification', handleSendPushNotification); fastify.post('/api/checkRateLimits', checkRateLimits); From 68e32b19426cf355eaf7c231f31635a0acbd3d80 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 11:47:31 -0400 Subject: [PATCH 6/8] Apply rate limiting uniformly to all Live Activity events All live activity pushes (start, update, end) now count against the rate limit. End events still have an APNs delivery cost, so exempting them was not justified. Co-Authored-By: Claude Sonnet 4.6 --- functions/ios.js | 6 +----- functions/test/fixtures/live-activity/end.json | 2 +- functions/test/ios.test.js | 12 +++++++----- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/functions/ios.js b/functions/ios.js index 4d0e7b6..00d91d4 100644 --- a/functions/ios.js +++ b/functions/ios.js @@ -1,9 +1,5 @@ 'use strict'; -// Live Activity 'end' events dismiss an ongoing activity rather than delivering new content, -// so they are exempt from rate limits — equivalent to clear_notification on Android. -const NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS = new Set(['end']); - module.exports = { createPayload: (req) => { // When live_activity_token is present, this is a Live Activity push notification. @@ -229,7 +225,7 @@ function buildLiveActivityPayload(req) { }; return { - updateRateLimits: !NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS.has(event), + updateRateLimits: true, payload, }; } diff --git a/functions/test/fixtures/live-activity/end.json b/functions/test/fixtures/live-activity/end.json index f3496b5..3d41bf9 100644 --- a/functions/test/fixtures/live-activity/end.json +++ b/functions/test/fixtures/live-activity/end.json @@ -20,7 +20,7 @@ } }, "expected": { - "updateRateLimits": false, + "updateRateLimits": true, "liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "apsEvent": "end", "contentState": { diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js index 2104ef4..848d684 100644 --- a/functions/test/ios.test.js +++ b/functions/test/ios.test.js @@ -283,10 +283,12 @@ describe('live-activity createPayload via FCM', () => { expect(payload.apns.headers['apns-topic']).toBeUndefined(); }); - test('end events do not update rate limits', () => { - const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); - const result = ios.createPayload(req); - expect(result.updateRateLimits).toBe(false); + test('all live activity events update rate limits', () => { + for (const event of ['start', 'update', 'end']) { + const req = createLiveActivityRequest({ data: { event, activity_id: 'test-001' } }); + const result = ios.createPayload(req); + expect(result.updateRateLimits).toBe(true); + } }); test('normal notifications (no live_activity_token) still work as before', () => { @@ -350,7 +352,7 @@ describe('handleRequest with Live Activity payload', () => { expect(mockMessaging.send).not.toHaveBeenCalled(); }); - test('does not update rate limits for end events', async () => { + test('updates rate limits for end events', async () => { const req = createLiveActivityRequest({ data: { event: 'end', activity_id: 'test-001' } }); await handleRequest(req, res, ios.createPayload); From e78990411473cc3aadccb5ce4bfcf5f3179ef4d5 Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 26 Mar 2026 11:56:37 -0400 Subject: [PATCH 7/8] Add test verifying attributes-type is only set on start events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HALiveActivityAttributes must only appear in push-to-start payloads — APNs rejects update/end payloads that include attributes-type. Co-Authored-By: Claude Sonnet 4.6 --- functions/test/ios.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js index 848d684..9f9e054 100644 --- a/functions/test/ios.test.js +++ b/functions/test/ios.test.js @@ -189,6 +189,17 @@ describe('live-activity createPayload via FCM', () => { expect(payload.apns.payload.aps.attributes).toEqual({ tag: 'laundry-001', title: 'Laundry' }); }); + test('attributes-type is only set for start events, not update or end', () => { + // HALiveActivityAttributes must only appear in push-to-start payloads. + // APNs rejects update/end payloads that include attributes-type. + for (const event of ['update', 'end']) { + const req = createLiveActivityRequest({ data: { event, activity_id: 'test-001' } }); + const { payload } = ios.createPayload(req); + expect(payload.apns.payload.aps['attributes-type']).toBeUndefined(); + expect(payload.apns.payload.aps.attributes).toBeUndefined(); + } + }); + test('end event includes dismissal-date when provided', () => { const req = createMockRequest({ body: { From 67feffdb5197724ae5f93a91603dad18dc752c5e Mon Sep 17 00:00:00 2001 From: rwarner Date: Thu, 2 Apr 2026 10:31:49 -0400 Subject: [PATCH 8/8] Fix Prettier formatting in ios.test.js Co-Authored-By: Claude Sonnet 4.6 --- functions/test/ios.test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/functions/test/ios.test.js b/functions/test/ios.test.js index 9f9e054..0a5e817 100644 --- a/functions/test/ios.test.js +++ b/functions/test/ios.test.js @@ -3,7 +3,13 @@ const path = require('path'); const fs = require('fs'); -const { createMockRequest, createMockResponse, createMockDocRef, createMockRateLimitData, setupFirestoreCollectionChain } = require('./utils/mock-factories'); +const { + createMockRequest, + createMockResponse, + createMockDocRef, + createMockRateLimitData, + setupFirestoreCollectionChain, +} = require('./utils/mock-factories'); const { assertResponse } = require('./utils/assertion-helpers'); // --- Mocks ---