feat(app) : phase 2 — endpoint /api/alerts, inbox, flows Node-RED

- POST /api/alerts : token Bearer (comparaison temps constant, 503 si non
  configuré), schéma d'alerte PRD §4.4 validé Zod, exempté du SSO dans les hooks
- Persistance alertes.yaml + commits git (réception, « traité »)
- Inbox /alertes : filtres par profil impacté et statut, actions « Marquer
  traité » et « Créer/MAJ fiche » avec pré-remplissage du formulaire registre
  depuis l'alerte (aucun champ licence/capacité inventé)
- nodered-flows/ : 4 flows importables (rss-ingest, license-watch, classifier
  avec le prompt figé du PRD §6 embarqué tel quel, notify) — nœuds core
  uniquement, configuration par variables d'env, README de câblage
- Vérifié : check/lint/test (27) /build verts, autofixer propre, JSON des flows
  et fonctions embarquées validés, test fumée HTTP (201/400/401 sur l'endpoint,
  inbox, traité + commit, pré-remplissage MAJ fiche)
This commit is contained in:
cyber-mawonaj
2026-08-01 10:40:18 -04:00
parent 0c9f83d3f5
commit 6978fbb5b8
20 changed files with 955 additions and 20 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso';
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
* toute autre route (pages et endpoints API) renvoie 401.
* Exception : POST /api/alerts, protégé par token Bearer au lieu du SSO (webhook Node-RED).
*/
const authHandle: Handle = async ({ event, resolve }) => {
event.locals.user = resoudreUtilisateur(event.request.headers, {
@@ -17,7 +18,8 @@ const authHandle: Handle = async ({ event, resolve }) => {
dev
});
if (!event.locals.user && !estRoutePublique(event.url.pathname, base)) {
const estWebhookAlertes = event.url.pathname === `${base}/api/alerts`;
if (!event.locals.user && !estRoutePublique(event.url.pathname, base) && !estWebhookAlertes) {
error(401, 'Authentification requise');
}
+26
View File
@@ -190,6 +190,32 @@ export const fr = {
genere_le: 'Généré le',
tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}'
},
alertes: {
titre: 'Inbox alertes',
intro: 'Événements classifiés reçus depuis Node-RED (POST /api/alerts).',
filtre_profil: 'Profil impacté',
filtre_statut: 'Statut',
tous: 'tous',
toutes: 'toutes',
a_traiter: 'à traiter',
traite: 'traitées',
marquer_traite: 'Marquer traité',
creer_fiche: 'Créer la fiche',
maj_fiche: 'Mettre à jour la fiche',
aucune: 'Aucune alerte pour ces filtres.',
recue_le: 'Reçue le',
urgence: {
haute: 'urgence haute',
moyenne: 'urgence moyenne',
faible: 'urgence faible'
},
types: {
nouvelle_sortie: 'nouvelle sortie',
changement_licence: 'changement de licence',
changement_classement: 'changement de classement',
comfyui_support: 'support ComfyUI'
}
},
pied: {
ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.',
registre_vide: 'Registre et profils initialisés depuis le PRD au premier démarrage.'
+34
View File
@@ -87,3 +87,37 @@ export const profilSchema = z.object({
contraintes_specifiques: z.string().nullable().default(null)
});
export type Profil = z.infer<typeof profilSchema>;
// ── Alertes (PRD §4.4) ──
export const typeAlerteSchema = z.enum([
'nouvelle_sortie',
'changement_licence',
'changement_classement',
'comfyui_support'
]);
export type TypeAlerte = z.infer<typeof typeAlerteSchema>;
export const urgenceSchema = z.enum(['haute', 'moyenne', 'faible']);
export type Urgence = z.infer<typeof urgenceSchema>;
/** Charge utile reçue sur POST /api/alerts (émise par le classifieur Node-RED, PRD §6). */
export const alerteEntranteSchema = z.object({
type: typeAlerteSchema,
modele_id: slugSchema.nullable().default(null),
titre: z.string().min(1),
resume: z.string().min(1),
impact_profils: z.array(z.string()).default([]),
urgence: urgenceSchema.default('moyenne'),
sources: z.array(z.string().url()).min(1, 'au moins une source est obligatoire'),
date: z.string().min(1)
});
export type AlerteEntrante = z.infer<typeof alerteEntranteSchema>;
/** Alerte persistée : charge utile + métadonnées ajoutées par le serveur. */
export const alerteSchema = alerteEntranteSchema.extend({
id: z.string().min(1),
recue_le: z.string().min(1),
statut: z.enum(['a_traiter', 'traite']).default('a_traiter')
});
export type Alerte = z.infer<typeof alerteSchema>;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { tokenValide } from './alertes';
describe('tokenValide', () => {
it('accepte le token exact', () => {
expect(tokenValide('secret-123', 'secret-123')).toBe(true);
});
it('rejette un token différent', () => {
expect(tokenValide('secret-124', 'secret-123')).toBe(false);
});
it('rejette un token de longueur différente', () => {
expect(tokenValide('secret-12', 'secret-123')).toBe(false);
});
it('rejette null des deux côtés', () => {
expect(tokenValide(null, 'secret-123')).toBe(false);
expect(tokenValide('secret-123', null)).toBe(false);
expect(tokenValide(null, null)).toBe(false);
});
it('rejette une chaîne vide', () => {
expect(tokenValide('', 'secret-123')).toBe(false);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { alerteSchema, type Alerte, type AlerteEntrante } from '../schemas';
import { committer } from './git';
import { initialiserDonnees } from './stockage';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
import { config } from './config';
/**
* Vérification du token Bearer de POST /api/alerts — fonction pure, testable.
* Comparaison en temps constant (jamais de === sur des secrets).
*/
export function tokenValide(tokenRecu: string | null, tokenAttendu: string | null): boolean {
if (!tokenRecu || !tokenAttendu) return false;
const a = Buffer.from(tokenRecu);
const b = Buffer.from(tokenAttendu);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
const FICHIER_ALERTES = 'alertes.yaml';
const alertesFichierSchema = z.array(alerteSchema);
function cheminAlertes(): string {
return join(config.dataDir, FICHIER_ALERTES);
}
export async function lireAlertes(): Promise<Alerte[]> {
await initialiserDonnees();
if (!existsSync(cheminAlertes())) return [];
const brut = await readFile(cheminAlertes(), 'utf8');
const charge = loadYaml(brut);
if (charge === null || charge === undefined) return [];
return alertesFichierSchema.parse(charge);
}
async function ecrireAlertes(alertes: Alerte[], messageCommit: string): Promise<void> {
const valides = alertesFichierSchema.parse(alertes);
await writeFile(cheminAlertes(), dumpYaml(valides, { lineWidth: 100, noRefs: true }), 'utf8');
await committer([FICHIER_ALERTES], messageCommit);
}
/** Ajoute une alerte en tête d'inbox (plus récente d'abord). */
export async function ajouterAlerte(alerte: AlerteEntrante): Promise<Alerte> {
const alertes = await lireAlertes();
const complete: Alerte = alerteSchema.parse({
...alerte,
id: crypto.randomUUID(),
recue_le: new Date().toISOString(),
statut: 'a_traiter'
});
await ecrireAlertes([complete, ...alertes], `feat : alerte reçue « ${complete.titre} »`);
return complete;
}
export async function marquerAlerteTraitee(id: string): Promise<boolean> {
const alertes = await lireAlertes();
const index = alertes.findIndex((a) => a.id === id);
if (index === -1) return false;
alertes[index] = { ...alertes[index], statut: 'traite' };
await ecrireAlertes(alertes, `feat : alerte « ${alertes[index].titre} » marquée traitée`);
return true;
}
+1 -2
View File
@@ -9,14 +9,13 @@
let { data, children }: LayoutProps = $props();
// Navigation privée : visible uniquement avec un utilisateur SSO.
// L'entrée "bientot" correspond à la phase 2 du PRD.
const entrees = [
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false },
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: false }
] as const;
</script>
+2 -9
View File
@@ -29,7 +29,7 @@
titre: fr.accueil.carte_alertes_titre,
texte: fr.accueil.carte_alertes_texte,
icone: 'alerte',
href: null
href: '/alertes'
}
] as const;
</script>
@@ -48,16 +48,9 @@
<article class="carte">
<h2>
<Icon nom={carte.icone} taille={22} />
{#if carte.href}
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
{:else}
{carte.titre}
{/if}
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
</h2>
<p>{carte.texte}</p>
{#if !carte.href}
<span class="tag">{fr.accueil.phase_a_venir}</span>
{/if}
</article>
{/each}
</div>
+21
View File
@@ -0,0 +1,21 @@
import { fail } from '@sveltejs/kit';
import { lireAlertes, marquerAlerteTraitee } from '$lib/server/alertes';
import { lireProfils } from '$lib/server/stockage';
import { chaineOuNull } from '$lib/server/formulaires';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [alertes, profils] = await Promise.all([lireAlertes(), lireProfils()]);
return { alertes, profils };
};
export const actions: Actions = {
traiter: async ({ request }) => {
const data = await request.formData();
const id = chaineOuNull(data.get('id'));
if (!id || !(await marquerAlerteTraitee(id))) {
return fail(404, { erreur: 'Alerte introuvable.' });
}
return { traitee: id };
}
};
+240
View File
@@ -0,0 +1,240 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import Icon from '$lib/components/Icon.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let filtreProfil = $state('');
let filtreStatut = $state<'a_traiter' | 'traite' | ''>('a_traiter');
const alertesFiltrees = $derived(
data.alertes.filter(
(a) =>
(filtreStatut === '' || a.statut === filtreStatut) &&
(filtreProfil === '' || a.impact_profils.includes(filtreProfil))
)
);
function nomProfil(id: string): string {
return data.profils.find((p) => p.id === id)?.nom ?? id;
}
function dateCourte(iso: string): string {
return iso.slice(0, 10);
}
</script>
<svelte:head>
<title>{fr.alertes.titre}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.alertes.titre}</h1>
<p class="intro">{fr.alertes.intro}</p>
{#if form && 'erreur' in form}
<p class="erreur" role="alert">{form.erreur}</p>
{/if}
<div class="filtres" role="search">
<label class="champ">
<span>{fr.alertes.filtre_statut}</span>
<select bind:value={filtreStatut}>
<option value="a_traiter">{fr.alertes.a_traiter}</option>
<option value="traite">{fr.alertes.traite}</option>
<option value="">{fr.alertes.toutes}</option>
</select>
</label>
<label class="champ">
<span>{fr.alertes.filtre_profil}</span>
<select bind:value={filtreProfil}>
<option value="">{fr.alertes.tous}</option>
{#each data.profils as profil (profil.id)}
<option value={profil.id}>{profil.nom}</option>
{/each}
</select>
</label>
</div>
{#if alertesFiltrees.length === 0}
<p>{fr.alertes.aucune}</p>
{:else}
<div class="liste">
{#each alertesFiltrees as alerte (alerte.id)}
<article class="carte alerte" class:traitee={alerte.statut === 'traite'}>
<header>
<span class="tag" class:tag-signal={alerte.urgence === 'haute'}>
{fr.alertes.types[alerte.type]} · {fr.alertes.urgence[alerte.urgence]}
</span>
<time datetime={alerte.recue_le}>
{fr.alertes.recue_le}
{dateCourte(alerte.recue_le)}
</time>
</header>
<h2>{alerte.titre}</h2>
<p class="resume">{alerte.resume}</p>
{#if alerte.impact_profils.length > 0}
<p class="impacts">
{#each alerte.impact_profils as profilId (profilId)}
<span class="tag">{nomProfil(profilId)}</span>
{/each}
</p>
{/if}
<p class="sources">
{#each alerte.sources as source (source)}
<a href={source} rel="external noopener noreferrer" target="_blank">{source}</a>
{/each}
</p>
<div class="actions">
{#if alerte.modele_id}
<a
class="bouton"
href={resolve(`/registre/${alerte.modele_id}?depuis_alerte=${alerte.id}`)}
>
<Icon nom="registre" taille={16} />
{fr.alertes.maj_fiche}
</a>
{:else}
<a class="bouton" href={resolve(`/registre/nouveau?depuis_alerte=${alerte.id}`)}>
<Icon nom="registre" taille={16} />
{fr.alertes.creer_fiche}
</a>
{/if}
{#if alerte.statut === 'a_traiter'}
<form method="post" action="?/traiter" use:enhance>
<input type="hidden" name="id" value={alerte.id} />
<button class="bouton bouton-secondaire" type="submit">
<Icon nom="verifie" taille={16} />
{fr.alertes.marquer_traite}
</button>
</form>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<style>
h1 {
margin-bottom: var(--space-1);
}
.intro {
color: var(--muted);
margin-bottom: var(--space-3);
}
.filtres {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.champ {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.champ > span {
color: var(--muted);
font-size: 0.9rem;
}
select {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.5em 0.75em;
min-height: 44px;
}
.liste {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.alerte {
content-visibility: auto;
contain-intrinsic-size: 180px;
}
.alerte.traitee {
opacity: 0.55;
}
.alerte header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.alerte time {
color: var(--muted);
font-size: 0.85rem;
}
.alerte h2 {
font-size: 1.05rem;
margin-bottom: var(--space-1);
}
.resume {
color: var(--muted);
margin-bottom: var(--space-2);
}
.impacts {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: var(--space-2);
}
.sources {
display: flex;
flex-direction: column;
font-size: 0.85rem;
margin-bottom: var(--space-2);
word-break: break-all;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.actions .bouton {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.bouton-secondaire {
color: var(--succes);
border-color: var(--succes);
}
.bouton-secondaire:hover {
color: var(--succes);
border-color: var(--succes);
opacity: 0.8;
}
.erreur {
color: var(--signal);
font-weight: 600;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
import { error, json } from '@sveltejs/kit';
import { alerteEntranteSchema } from '$lib/schemas';
import { ajouterAlerte, tokenValide } from '$lib/server/alertes';
import { config } from '$lib/server/config';
import type { RequestHandler } from './$types';
/**
* POST /api/alerts (PRD §4.4) — reçoit les événements classifiés depuis Node-RED.
* Protégé par token Bearer (ALERTS_TOKEN, généré à l'install YunoHost) — pas par le SSO
* (cf. hooks.server : cette route est exemptée du 401 SSO, le token fait foi).
*/
export const POST: RequestHandler = async ({ request }) => {
if (!config.alertsToken) {
error(503, 'ALERTS_TOKEN non configuré sur ce serveur');
}
const autorisation = request.headers.get('authorization');
const token = autorisation?.startsWith('Bearer ') ? autorisation.slice(7).trim() : null;
if (!tokenValide(token, config.alertsToken)) {
error(401, 'Token invalide');
}
let corpsBrut: unknown;
try {
corpsBrut = await request.json();
} catch {
error(400, 'Corps JSON invalide');
}
const corps = alerteEntranteSchema.safeParse(corpsBrut);
if (!corps.success) {
error(
400,
`Alerte invalide : ${corps.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join(', ')}`
);
}
const alerte = await ajouterAlerte(corps.data);
return json({ id: alerte.id }, { status: 201 });
};
+20 -2
View File
@@ -1,14 +1,32 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { modeleSchema } from '$lib/schemas';
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
import { lireAlertes } from '$lib/server/alertes';
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, url }) => {
const registre = await lireRegistre();
const modele = registre.find((m) => m.id === params.id);
if (!modele) error(404, 'Modèle introuvable');
return { modele };
// Pré-remplissage depuis une alerte (action « Mettre à jour la fiche » de l'inbox) :
// fusionne les sources, préfixe les notes du résumé — sans toucher aux champs vérifiés.
const alerteId = url.searchParams.get('depuis_alerte');
let brut: Record<string, unknown> | null = null;
if (alerteId) {
const alertes = await lireAlertes();
const alerte = alertes.find((a) => a.id === alerteId);
if (alerte) {
brut = {
...modele,
sources: [...new Set([...modele.sources, ...alerte.sources])],
notes: `${alerte.resume}\n\n${modele.notes ?? ''}`.trim()
};
}
}
return { modele, brut };
};
export const actions: Actions = {
+1 -1
View File
@@ -17,5 +17,5 @@
edition
actionSupprimer="?/supprimer"
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
/>
@@ -1,8 +1,31 @@
import { fail, redirect } from '@sveltejs/kit';
import { modeleSchema } from '$lib/schemas';
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
import { lireAlertes } from '$lib/server/alertes';
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
import type { Actions } from './$types';
import type { Actions, PageServerLoad } from './$types';
/**
* Pré-remplissage depuis une alerte (action « Créer la fiche » de l'inbox) :
* reprend les sources et le résumé — sans inventer aucun champ licence/capacité.
*/
export const load: PageServerLoad = async ({ url }) => {
const alerteId = url.searchParams.get('depuis_alerte');
if (!alerteId) return { brut: null };
const alertes = await lireAlertes();
const alerte = alertes.find((a) => a.id === alerteId);
if (!alerte) return { brut: null };
return {
brut: {
id: alerte.modele_id ?? '',
nom: alerte.modele_id ? '' : alerte.titre,
sources: alerte.sources,
notes: `${alerte.resume}\n\n(alerte du ${alerte.date}, type : ${alerte.type})`
}
};
};
export const actions: Actions = {
default: async ({ request }) => {
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ModeleForm from '$lib/components/ModeleForm.svelte';
import type { ActionData } from './$types';
import type { ActionData, PageData } from './$types';
let { form }: { form: ActionData } = $props();
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
@@ -14,5 +14,5 @@
<ModeleForm
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
/>