P4-B: notifications Web Push (VAPID) sur passage en waiting

- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 12:13:56 +02:00
parent c43fb25c94
commit 47423c0651
13 changed files with 493 additions and 11 deletions

View File

@@ -0,0 +1,116 @@
// 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';
// 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<unknown>;
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,
) {
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
let pub = getSetting(db, 'vapid_public');
let priv = getSetting(db, 'vapid_private');
if (!pub || !priv) {
const keys = webpush.generateVAPIDKeys();
pub = keys.publicKey;
priv = keys.privateKey;
setSetting(db, 'vapid_public', pub);
setSetting(db, 'vapid_private', 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<void> {
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);
}
}),
);
}
}