import { promises as fs } from "node:fs"; import path from "node:path"; /** * Server-side push subscriptions: one JSON file with endpoint + keys and a * creation timestamp. No personal data, no history, no user ids — and the * whole feature stays hidden unless VAPID keys are configured. */ export type PushSubscriptionRecord = { endpoint: string; keys: { p256dh: string; auth: string }; 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 } }, /** Cap on stored subscriptions — the configured, sane one. */ max = 50, ): Promise<{ count: number; accepted: boolean }> { const current = await readSubscriptions(dir); const exists = current.some((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 (!exists && next.length >= max) return { count: current.length, accepted: false }; next.push({ ...subscription, 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; }