Gitea - lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible - retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests) Découverte des dépôts - aucune racine de scan par défaut → pas de scan au premier démarrage (clean install) - corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille) Sécurité « enterprise-deployable » - en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré - permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations - chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox - journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages - SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI CI - pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » : corrige l'embarquement de @arboretum/shared dans le tarball Purge des données personnelles du dépôt public - suppression de spikes/s4-discovery/result.json, reformulation du VERDICT - chemin de test générique, anonymisation des 5 fixtures de dialogues Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
// 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<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,
|
|
box?: SecretBox,
|
|
) {
|
|
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[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<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);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|