// PushService : notifications Web Push (VAPID). Clés VAPID générées une fois au bootstrap // et stockées dans `settings` (comme le server_secret de l'auth) ; abonnements liés au token // d'auth (token_id). Quand une session managée passe en `waiting`, pty-manager appelle notify(). import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; import { type Db, getSetting, setSetting } from '../db/index.js'; import type { SecretBox } from './secret-box.js'; import { recordAudit } from './audit-log.js'; // web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext), // typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts. const require = createRequire(import.meta.url); const webpush = require('web-push') as typeof import('web-push'); export interface PushSubscriptionInput { endpoint: string; keys: { p256dh: string; auth: string }; } /** Charge utile JSON poussée au service worker (cf. packages/web/src/sw.ts). */ export interface PushPayload { sessionId: string; title: string; body: string; kind: string | null; url: string; } interface SubRow { id: string; endpoint: string; p256dh: string; auth: string; } /** Envoi d'une notif à un abonnement — injectable pour les tests ; défaut = web-push réel. */ export type PushSender = ( subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, payload: string, options: unknown, ) => Promise; export class PushService { private readonly vapidPublic: string; private readonly vapidPrivate: string; private readonly send: PushSender; constructor( private readonly db: Db, private readonly contact: string = 'mailto:arboretum@localhost', sender?: PushSender, box?: SecretBox, ) { this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters[2])); let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer) const storedPriv = getSetting(db, 'vapid_private'); let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null; if (!pub || !priv) { const keys = webpush.generateVAPIDKeys(); pub = keys.publicKey; priv = keys.privateKey; setSetting(db, 'vapid_public', pub); setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv); recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' }); } else if (box && storedPriv && !box.isEncrypted(storedPriv)) { // migration douce : clé privée pré-existante en clair → re-chiffrée. setSetting(db, 'vapid_private', box.encrypt(priv)); } this.vapidPublic = pub; this.vapidPrivate = priv; } /** Clé publique VAPID — sûre à exposer (applicationServerKey côté navigateur). */ publicKey(): string { return this.vapidPublic; } /** Enregistre (ou ré-associe) un abonnement, lié au token authentifié. */ subscribe(tokenId: string, sub: PushSubscriptionInput, userAgent: string | null): void { this.db .prepare( `INSERT INTO push_subscriptions (id, token_id, endpoint, p256dh, auth, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET token_id = excluded.token_id, p256dh = excluded.p256dh, auth = excluded.auth, user_agent = excluded.user_agent`, ) .run(randomUUID(), tokenId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, userAgent, new Date().toISOString()); } unsubscribe(endpoint: string): void { this.db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); } count(): number { return (this.db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get() as { n: number }).n; } /** * Pousse une notification à TOUS les abonnements (un seul utilisateur, plusieurs appareils). * Un abonnement expiré (404/410 Gone) est purgé ; les autres erreurs sont ignorées (best-effort, * un appareil injoignable ne doit pas bloquer les autres). */ async notify(payload: PushPayload): Promise { const rows = this.db.prepare('SELECT id, endpoint, p256dh, auth FROM push_subscriptions').all() as unknown as SubRow[]; if (rows.length === 0) return; const body = JSON.stringify(payload); const options = { vapidDetails: { subject: this.contact, publicKey: this.vapidPublic, privateKey: this.vapidPrivate }, TTL: 60, }; await Promise.all( rows.map(async (r) => { try { await this.send({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }, body, options); this.db.prepare('UPDATE push_subscriptions SET last_ok_at = ? WHERE id = ?').run(new Date().toISOString(), r.id); } catch (err) { const code = (err as { statusCode?: number }).statusCode; if (code === 404 || code === 410) this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(r.id); } }), ); } }