import { promises as fs } from "node:fs"; import path from "node:path"; /** * Server-side push subscriptions: one JSON file with endpoint + keys, the * reminder text this browser asked for, and a creation timestamp. No user ids, * no history, no note copy — the reminder text is what the user's own browser * composed for the notification (it may name one of their decks), and it is * sent back to them unchanged. The whole feature stays hidden unless VAPID * keys are configured. */ /** What the notification will say; composed in the browser (lib/reminder.ts). */ export type PushReminderMessage = { title: string; body: string; url?: string }; export type PushSubscriptionRecord = { endpoint: string; keys: { p256dh: string; auth: string }; /** Absent for a subscription stored before reminders carried a message. */ message?: PushReminderMessage; createdAt: number; }; function fileFor(dir: string): string { return path.join(dir, "subscriptions.json"); } export async function readSubscriptions(dir: string): Promise { try { const raw = await fs.readFile(fileFor(dir), "utf8"); const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter( (entry): entry is PushSubscriptionRecord => Boolean(entry) && typeof (entry as PushSubscriptionRecord).endpoint === "string" && typeof (entry as PushSubscriptionRecord).keys?.p256dh === "string" && typeof (entry as PushSubscriptionRecord).keys?.auth === "string", ); } catch { return []; } } async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): Promise { await fs.mkdir(dir, { recursive: true }); const file = fileFor(dir); const temporary = `${file}.${crypto.randomUUID()}.tmp`; await fs.writeFile(temporary, JSON.stringify(next, null, 2), "utf8"); await fs.rename(temporary, file); } /** Adds or refreshes a subscription (deduped by endpoint). */ export async function addSubscription( dir: string, subscription: { endpoint: string; keys: { p256dh: string; auth: string }; message?: PushReminderMessage }, /** Cap on stored subscriptions — the configured, sane one. */ max = 50, ): Promise<{ count: number; accepted: boolean }> { const current = await readSubscriptions(dir); const previous = current.find((entry) => entry.endpoint === subscription.endpoint); const next = current.filter((entry) => entry.endpoint !== subscription.endpoint); // Refreshing a device that is already subscribed always works; a brand new // device over the cap is refused. if (previous === undefined && next.length >= max) return { count: current.length, accepted: false }; /* A refresh may carry a newer message; without one the device keeps the reminder it already had, and it keeps its original timestamp. */ const message = subscription.message ?? previous?.message; next.push({ endpoint: subscription.endpoint, keys: subscription.keys, ...(message ? { message } : {}), createdAt: previous?.createdAt ?? Date.now(), }); const capped = next.slice(-max); await writeSubscriptions(dir, capped); return { count: capped.length, accepted: true }; } export async function removeSubscription(dir: string, endpoint: string): Promise { const current = await readSubscriptions(dir); const next = current.filter((entry) => entry.endpoint !== endpoint); await writeSubscriptions(dir, next); return next.length; }