Lot 12 : notifications Web Push tenues par la façade (LOKI_PUSH=rust)

La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ;
le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement
complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT
ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls.
Mêmes clés en base que le Go.

Côté Go : facade_push.go remet les routes du tunnel à la façade, et
sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi.

Parité : parity_push_test.go tire du Go les durées et onze appels de route
avec la liste des abonnements en base, rejoués par tests/push.rs ; le
chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi
de bout en bout est déchiffré côté navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 17:01:07 +00:00
1 parent cbd0d5e356
commit eddcf97e3b
20 files changed
+1320 -5

No files matched your search

+3 -1
View File
@@ -143,7 +143,7 @@ var facadeChatRoutes = map[string]bool{
// withFacadeChat remet la requête à la façade quand elle tient la
// conversation — ou les routes des modèles (facade_models.go), la dictée
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
// de la discussion (facade_files.go).
// de la discussion (facade_files.go), les notifications (facade_push.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
@@ -158,6 +158,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeSettings, "réglages indisponibles : "
case facadeFilesRoutes[path]:
held, unavailable = facadeFiles, "fichiers indisponibles : "
case facadePushRoutes[path]:
held, unavailable = facadePush, "notifications indisponibles : "
default:
return h
}
+27
View File
@@ -484,3 +484,30 @@ func TestFacadeFichiers(t *testing.T) {
t.Fatalf(".part gardé sans la variable : %v", err)
}
}
// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les
// envois de ce process (fin de tour, fin de tâche) lui sont délégués.
func TestFacadeNotifications(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadePushEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadePushRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
sendPushToAll("Loki", "Réponse prête · 3s")
got := f.recu()
if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` {
t.Fatalf("reçu par la façade : %v", got)
}
t.Setenv(facadePushEnv, "")
sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué
if n := len(f.recu()); n != len(facadePushRoutes)+1 {
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
}
}
+43
View File
@@ -0,0 +1,43 @@
package loki
import (
"fmt"
"os"
"strings"
)
// facade_push.go — notifications Web Push tenues par la façade Rust
// (LOKI_PUSH=rust, lot 12 de la migration ; expérimental, le Go reste le
// défaut).
//
// La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
// les notifications : les envois de ce process (fin de tour, fin de tâche) lui
// sont délégués par /internal/facade/v1/push/send. Clés et abonnements restent
// en base aux mêmes clés : hasPushSubs les lit toujours ici.
const facadePushEnv = "LOKI_PUSH"
// facadePush : la façade tient-elle les notifications ?
func facadePush() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePushEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadePushRoutes : les routes que la façade sert alors (push.rs).
var facadePushRoutes = map[string]bool{
"/api/push/key": true,
"/api/push/subscribe": true,
"/api/push/unsubscribe": true,
}
// facadePushSend délègue un envoi à la façade ; false quand elle ne tient pas
// les notifications (l'appelant envoie alors lui-même).
func facadePushSend(title, body string) bool {
if !facadePush() {
return false
}
if err := facadePost("/internal/facade/v1/push/send", map[string]any{"title": title, "body": body}); err != nil {
fmt.Printf("[push] envoi délégué à la façade : %v\n", err)
}
return true
}
+109
View File
@@ -0,0 +1,109 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// parity_push_test.go — journal de référence des notifications Web Push (lot
// 12 de la migration Rust) : clé VAPID (générée une fois, stable), abonnement,
// désabonnement, et le texte du corps de notification. Rangé dans
// rust/loki-rs/tests/parity_push/push.json ; tests/push.rs le rejoue par la
// façade. Les clés tirées au hasard sont masquées (seule leur forme compte).
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications
const parityPushFile = "../../rust/loki-rs/tests/parity_push/push.json"
type parityPushCall struct {
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
// Après l'appel : les abonnements enregistrés (JSON brut, tel qu'en base).
Subs string `json:"subs"`
}
type parityPushFixture struct {
Durations map[string]string `json:"durations"`
Calls []parityPushCall `json:"calls"`
}
func TestPariteNotifications(t *testing.T) {
testHome(t)
var fx parityPushFixture
fx.Durations = map[string]string{}
for _, ms := range []int64{0, 400, 600, 42_000, 185_000, 3_600_000, 4_330_000, -5_000} {
fx.Durations[strings.TrimSpace(time.Duration(ms*int64(time.Millisecond)).String())] = fmtDurFR(time.Duration(ms) * time.Millisecond)
}
keyRe := regexp.MustCompile(`"key":"[A-Za-z0-9_-]{87}"`)
call := func(method, path, body string) parityPushCall {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
switch path {
case "/api/push/key":
handlePushKey(w, r)
case "/api/push/subscribe":
handlePushSubscribe(w, r)
case "/api/push/unsubscribe":
handlePushUnsubscribe(w, r)
default:
t.Fatalf("route inattendue %s", path)
}
out := keyRe.ReplaceAllString(w.Body.String(), `"key":"{{CLE}}"`)
c := parityPushCall{Method: method, Path: path, Body: body, Status: w.Code, Subs: string(getBytes(bkState, stPushSubs))}
if err := json.Unmarshal([]byte(out), &c.Out); err != nil {
t.Fatalf("%s : %v (%s)", path, err, w.Body.String())
}
return c
}
for _, c := range []struct{ m, p, b string }{
{"GET", "/api/push/key", ""},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BAAA","auth":"x1"}}`},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/b","keys":{"p256dh":"BBBB","auth":"x2"},"expirationTime":null}`},
{"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BCCC","auth":"x3"}}`},
{"POST", "/api/push/subscribe", `{"keys":{"p256dh":"B","auth":"y"}}`},
{"POST", "/api/push/subscribe", `pas du json`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/inconnu"}`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/a"}`},
{"POST", "/api/push/unsubscribe", `pas du json`},
{"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/b"}`},
{"GET", "/api/push/key", ""},
} {
fx.Calls = append(fx.Calls, call(c.m, c.p, c.b))
}
// La paire VAPID est stable : la clé servie est la clé rangée.
if pub := getStr(bkState, stPushVAPIDPub); len(pub) != 87 || len(getStr(bkState, stPushVAPIDPriv)) != 43 {
t.Fatalf("paire VAPID inattendue : %q", pub)
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityPushFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityPushFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityPushFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les notifications ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications,\n"+
"puis cargo test --test push dit si le port Rust suit.\n--- obtenu ---\n%s", parityPushFile, got)
}
}
+4
View File
@@ -150,6 +150,10 @@ func hasPushSubs() bool { return len(loadSubs()) > 0 }
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
func sendPushToAll(title, body string) {
// Notifications tenues par la façade (LOKI_PUSH=rust) : c'est elle qui pousse.
if facadePushSend(title, body) {
return
}
subs := loadSubs()
if len(subs) == 0 {
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
+3 -2
View File
@@ -223,8 +223,9 @@ func newWebMux() *http.ServeMux {
// conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand
// elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée
// (LOKI_DICTATE=rust, facade_dictate.go), des réglages partagés
// (LOKI_SETTINGS=rust, facade_settings.go) et des fichiers de la
// discussion (LOKI_FILES=rust, facade_files.go) ; sans effet sinon.
// (LOKI_SETTINGS=rust, facade_settings.go), des fichiers de la discussion
// (LOKI_FILES=rust, facade_files.go) et des notifications (LOKI_PUSH=rust,
// facade_push.go) ; sans effet sinon.
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)