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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user