feat(push): FCM sender + /push/fcm-token route for the Android client (A33)

Mirror src/push/apns.ts behind the same NotifyService seam (combineNotifyServices),
so held-gate/done hook events fan out to Android via data-only high-priority FCM with
zero new event paths. OAuth2 bearer via google-auth-library (R7); payload minimized to
sessionId/cls/token (never cwd/command/bytes); intentionally-loose FCM-token validator;
Origin-guarded + rate-limited POST/DELETE /push/fcm-token; FCM_* env all-or-disabled.

Cross-review fix: dropped validateStatus:()=>true so google-auth-library's built-in
401 refresh-and-retry actually fires. 60 new tests; full server suite green; tsc clean.
This commit is contained in:
Yaojia Wang
2026-07-10 16:40:43 +02:00
parent e7f3bd05f0
commit 542fde9580
5 changed files with 1798 additions and 2 deletions

View File

@@ -47,6 +47,7 @@ import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import { initFcm, normalizeFcmToken } from './push/fcm.js'
import type {
Config,
NotifyService,
@@ -196,8 +197,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// web-push through the SAME NotifyService seam, so every existing hook-event
// call site below stays untouched: `pushService` is the combined fan-out.
const apns = initApns(cfg, process.env as Record<string, string | undefined>)
// A33: optional FCM sender (Android client) — enabled only when the FCM_* env
// group is complete (else null + one log line, never a crash). It fans out
// beside web-push and APNs through the SAME NotifyService seam.
const fcm = initFcm(cfg, process.env as Record<string, string | undefined>)
const extraNotifyServices: NotifyService[] = [
...(apns !== null ? [apns.service] : []),
...(fcm !== null ? [fcm.service] : []),
]
const pushService: NotifyService =
apns === null ? webPushService : combineNotifyServices(webPushService, apns.service)
extraNotifyServices.length === 0
? webPushService
: combineNotifyServices(webPushService, ...extraNotifyServices)
const manager = createSessionManager(cfg, pushService)
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
@@ -432,7 +443,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// reachable "walked-away" device (T-iOS-20).
const hasPushTarget =
(webPushService.isEnabled() && subStore.list().length > 0) ||
(apns !== null && apns.store.list().length > 0)
(apns !== null && apns.store.list().length > 0) ||
(fcm !== null && fcm.store.list().length > 0)
if (!hasWatcher && !hasPushTarget) {
res.json({})
return
@@ -557,6 +569,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
}
// ── A33: FCM device-token registry (Android client) ───────────────────────
// Mounted only when the FCM_* env group is complete (disabled ⇒ 404). Frozen
// wire shape: POST/DELETE /push/fcm-token {token: loose base64url+':' string,
// ≤ bound} → 204 idempotent upsert/remove; 400 invalid; Origin guard 403;
// 5/min/IP 429; body ≤ 8kb — mirrors /push/apns-token above.
if (fcm !== null) {
const fcmStore = fcm.store
const fcmTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
app.post('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
if (token === null) {
res.status(400).end()
return
}
try {
fcmStore.add({ token, createdAt: Date.now() })
await fcmStore.persist()
} catch {
res.status(400).end()
return
}
res.status(204).end()
})
app.delete('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
if (token === null) {
res.status(400).end()
return
}
fcmStore.remove(token) // idempotent: unknown token still 204
await fcmStore.persist()
res.status(204).end()
})
}
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
// loopback) → guard with Origin + a per-decision capability token (stale/
// mismatch → 403).