maestro/src/vapid-store.ts
2026-06-03 05:08:00 +00:00

151 lines
5.2 KiB
TypeScript

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<VapidKeyMaterial>;
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<string, VapidKeyMaterial>();
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;
}
}