import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import webPush from 'web-push'; import { logger } from './logger.js'; /** * VAPID key material for Web Push (RFC 8292). * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. * * - `keyId` identifies which key generation a subscription was created under, * so after rotation we can still push to old subscriptions until they fail * with 410 and self-clean. * - `subject` MUST be a `mailto:` or `https:` URL per RFC 8292; FCM has been * observed to rate-limit generic `mailto:` values, so an operations URL is * the recommended default. */ export interface VapidKeyMaterial { keyId: string; publicKey: string; privateKey: string; subject: string; createdAt: string; } const FILE_MODE = 0o600; function newKeyId(): string { const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const suffix = Math.random().toString(36).slice(2, 8); return `v1-${date}-${suffix}`; } function readKeyFile(path: string): VapidKeyMaterial { const raw = readFileSync(path, 'utf-8'); const parsed = JSON.parse(raw) as Partial; if (!parsed.keyId || !parsed.publicKey || !parsed.privateKey || !parsed.subject) { throw new Error( `[vapid-store] malformed key file at ${path} (missing keyId/publicKey/privateKey/subject)`, ); } return { keyId: parsed.keyId, publicKey: parsed.publicKey, privateKey: parsed.privateKey, subject: parsed.subject, createdAt: parsed.createdAt ?? new Date().toISOString(), }; } function writeKeyFile(path: string, material: VapidKeyMaterial): void { const dir = dirname(path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(path, JSON.stringify(material, null, 2), { mode: FILE_MODE }); } /** * Manages VAPID key lifecycle: load-or-generate, history retrieval, rotation. * * Operational model: * - `currentPath` holds the active keypair used for new subscriptions. * - `historyDir` accumulates retired keys so push to old subscriptions still * works during the grace period. * - Rotation is a manual CLI step (`scripts/vapid-rotate.ts`); subscriptions * carry the `keyId` they were created under so we can pick the right key * per-send. */ export class VapidKeyStore { private current: VapidKeyMaterial | null = null; private history = new Map(); constructor( private readonly currentPath: string, private readonly historyDir: string, ) {} /** * Load the current key from disk, generating a fresh one if missing. * Idempotent — repeated calls return the in-memory copy. */ loadOrGenerate(subject: string): VapidKeyMaterial { if (this.current) return this.current; if (existsSync(this.currentPath)) { this.current = readKeyFile(this.currentPath); logger.info(`[vapid-store] loaded VAPID key keyId=${this.current.keyId}`); return this.current; } const generated = webPush.generateVAPIDKeys(); this.current = { keyId: newKeyId(), publicKey: generated.publicKey, privateKey: generated.privateKey, subject, createdAt: new Date().toISOString(), }; writeKeyFile(this.currentPath, this.current); logger.info( `[vapid-store] generated new VAPID key keyId=${this.current.keyId} path=${this.currentPath}`, ); return this.current; } /** Current key (must call loadOrGenerate first). */ getCurrent(): VapidKeyMaterial { if (!this.current) { throw new Error('[vapid-store] getCurrent called before loadOrGenerate'); } return this.current; } /** * Look up a specific key by ID. Returns the current key if it matches, * otherwise loads from history (cached after first lookup). Returns null * when the key is unknown — caller should treat the subscription as * needing re-subscribe. */ getKey(keyId: string): VapidKeyMaterial | null { if (this.current && this.current.keyId === keyId) return this.current; const cached = this.history.get(keyId); if (cached) return cached; const historyPath = join(this.historyDir, `${keyId}.json`); if (!existsSync(historyPath)) return null; const material = readKeyFile(historyPath); this.history.set(keyId, material); return material; } /** * Rotate to a fresh keypair. The old current key is moved to history * (filename = old keyId). Returns the new material. * * Subscriptions in the DB are *not* touched here — they continue to carry * the old keyId, and the next push for each will be sent with the * preserved old key (until 410 / re-subscribe clears them). */ rotate(subject: string): VapidKeyMaterial { const old = this.loadOrGenerate(subject); if (!existsSync(this.historyDir)) { mkdirSync(this.historyDir, { recursive: true, mode: 0o700 }); } const historyPath = join(this.historyDir, `${old.keyId}.json`); renameSync(this.currentPath, historyPath); this.history.set(old.keyId, old); this.current = null; const fresh = this.loadOrGenerate(subject); logger.info( `[vapid-store] rotated VAPID key oldKeyId=${old.keyId} newKeyId=${fresh.keyId}`, ); return fresh; } }