Closer/functions/src/backup/onRestoreRequested.ts

159 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { recipientInQuietHours } from '../notifications/quietHours'
const CHANNEL = 'partner_activity'
// Suppress duplicate "was this you?" pushes when a request doc is rapidly deleted+recreated (a compromised
// account could loop that to spam both partners). The in-app queue entry is still written every time.
export const SELF_ALERT_DEDUPE_MS = 60 * 1000
/** Pure dedupe decision: allow a self-alert push only if none was sent within the window. */
export function selfAlertAllowed(lastAlertAt: unknown, now: number): boolean {
if (typeof lastAlertAt !== 'number') return true
return now - lastAlertAt >= SELF_ALERT_DEDUPE_MS
}
/** Pure guard: fire the completion alert only on the single REQUESTED→READY status transition. */
export function isRestoreReadyTransition(beforeStatus: unknown, afterStatus: unknown): boolean {
return afterStatus === 'READY' && beforeStatus !== 'READY'
}
/** All FCM tokens for a user: the legacy single field + the multi-device `fcmTokens` subcollection. */
async function gatherTokens(db: admin.firestore.Firestore, uid: string): Promise<string[]> {
const tokens: string[] = []
const userDoc = await db.collection('users').doc(uid).get()
const legacy = userDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const tokenSnap = await db.collection('users').doc(uid).collection('fcmTokens').get()
tokenSnap.docs.forEach((d) => {
const t = d.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
return tokens
}
/**
* Write the durable in-app queue entry (always) and — unless quiet hours suppress it — push to every device.
* `bypassQuietHours` is for security signals (the "was this you?" self-alert) that must not be silenced.
*/
async function queueAndPush(
db: admin.firestore.Firestore,
uid: string,
opts: { type: string; title: string; body: string; coupleId: string; bypassQuietHours?: boolean }
): Promise<void> {
const { type, title, body, coupleId, bypassQuietHours } = opts
await db.collection('users').doc(uid).collection('notification_queue').add({
type, title, body, read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const userDoc = await db.collection('users').doc(uid).get()
if (!userDoc.exists) return
if (!bypassQuietHours && recipientInQuietHours(userDoc.data())) return
const tokens = await gatherTokens(db, uid)
if (tokens.length === 0) return
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
notification: { title, body },
data: { type, couple_id: coupleId },
token,
android: { notification: { channelId: CHANNEL } },
} as admin.messaging.Message)
)
)
results.forEach((r, i) => {
if (r.status === 'rejected') console.warn(`[restore] FCM failed for ${tokens[i]}:`, r.reason)
})
}
/**
* Fires when a member starts a partner-assisted restore
* (`couples/{coupleId}/restore_requests/{recipientUid}` created on a new/wiped device). Sends TWO
* notifications, isolated so one failing never blocks the other:
* 1. To the OTHER partner — "help them restore" (high-signal; only quiet hours suppress it).
* 2. To the RECIPIENT'S OWN devices — a security "was this you?" alert. If the real owner still holds a
* device (a phished password without device loss), this is how they learn a restore is happening.
* No key material is read or logged — the request carries only a public key + a nonce.
*/
export const onRestoreRequested = functions.firestore
.document('couples/{coupleId}/restore_requests/{recipientUid}')
.onCreate(async (_snap, context) => {
const { coupleId, recipientUid } = context.params as { coupleId: string; recipientUid: string }
const db = admin.firestore()
const coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get()
if (!coupleDoc.exists) return
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
// The recipient must be a member; notify the OTHER member (the partner who can help).
if (!userIds.includes(recipientUid)) return
const partnerId = userIds.find((u) => u !== recipientUid)
if (!partnerId) return
// Audit trail (no key material — actor/recipient/timestamp only).
console.log(`[onRestoreRequested] couple=${coupleId} recipient=${recipientUid} partner=${partnerId}`)
// 1) Partner "help them restore" — routine quiet hours apply.
try {
await queueAndPush(db, partnerId, {
type: 'restore_requested',
title: 'Help your partner restore 💜',
body: 'Theyre setting up on a new device. Tap to help.',
coupleId,
})
} catch (e) {
console.warn('[onRestoreRequested] partner notify failed:', e)
}
// 2) Recipient self-alert — security signal, bypasses quiet hours, deduped against create-loops.
try {
const now = Date.now()
if (selfAlertAllowed(coupleDoc.data()?.lastRestoreSelfAlertAt, now)) {
await coupleRef.set({ lastRestoreSelfAlertAt: now }, { merge: true })
await queueAndPush(db, recipientUid, {
type: 'restore_self_alert',
title: 'New device is restoring your history',
body: 'If this wasnt you, secure your account now.',
coupleId,
bypassQuietHours: true,
})
}
} catch (e) {
console.warn('[onRestoreRequested] self-alert failed:', e)
}
})
/**
* Fires when the partner writes the keybox (status flips to READY) — the moment the couple key actually
* transfers. Alerts the RECIPIENT'S OWN devices that a restore just completed, the strongest "it happened"
* signal for the real owner. Guarded to the single REQUESTED→READY transition (ignores decline/expire).
*/
export const onRestoreFulfilled = functions.firestore
.document('couples/{coupleId}/restore_requests/{recipientUid}')
.onUpdate(async (change, context) => {
const before = change.before.data()
const after = change.after.data()
if (!isRestoreReadyTransition(before?.status, after?.status)) return
const { coupleId, recipientUid } = context.params as { coupleId: string; recipientUid: string }
const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(recipientUid)) return
console.log(`[onRestoreFulfilled] couple=${coupleId} recipient=${recipientUid}`)
try {
await queueAndPush(db, recipientUid, {
type: 'restore_self_alert',
title: 'Your history was just restored',
body: 'A new device now has access. If this wasnt you, secure your account now.',
coupleId,
bypassQuietHours: true,
})
} catch (e) {
console.warn('[onRestoreFulfilled] self-alert failed:', e)
}
})