diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b1d5295..26560a6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md). ## Versione attuale — agosto 2026 +### Le notifiche push arrivano anche ad app chiusa + +- Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291) + invece di essere recuperato dal service worker con una fetch al risveglio: era quella fetch + a non arrivare mai in tempo a telefono bloccato, e la notifica non compariva affatto + (DD-026). +- Spariscono la route `/api/public/push-messaggio`, la coda `promemoria_push` e + `messaggioPalloniOggi()`: esistevano solo per rimediare al payload vuoto. + ### Lo Scout Live si apre dalla pagina della partita - Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d9557e0..4102783 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -73,7 +73,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa | -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | | `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | -| `promemoria_push` | Storico dei promemoria inviati. | | +| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index abe2946..93e80d6 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -41,6 +41,7 @@ Serve a rispondere a domande del tipo: | [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo | | [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | | [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | +| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | **In valutazione** @@ -843,8 +844,9 @@ i chiamanti sono diversi (`src/lib/auth-route.server.ts`): - `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025). -`csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il -browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico. +`csi`, `push-config` e `push-subscribe` restano aperte: le chiama il browser prima del login, +dove qualsiasi segreto sarebbe pubblico. (`push-messaggio` esisteva per lo stesso motivo ed è +sparita con DD-026.) **Alternative scartate** @@ -894,10 +896,9 @@ riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e `richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono. -Il testo dell'avviso viaggia in coda su `promemoria_push`: la push parte vuota e il service -worker chiede a `push-messaggio` cosa mostrare, ma quella route sa raccontare solo la giornata -corrente. Senza la coda, un avviso mandato il martedì per il sabato arriverebbe con il testo -generico. +Il testo dell'avviso viaggia dentro la push, cifrato nel payload (DD-026): il service worker +lo mostra così com'è, quindi un avviso mandato il martedì per il sabato arriva col testo +giusto. **Alternative scartate** @@ -907,19 +908,65 @@ generico. - Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che non esiste. -- Calcolare il testo al volo come fa `messaggioPalloniOggi` → funziona solo se l'avviso parte - il giorno stesso, cioè proprio il vincolo da cui volevamo uscire. +- Calcolare il testo al volo sulla giornata corrente → funziona solo se l'avviso parte il + giorno stesso, cioè proprio il vincolo da cui volevamo uscire. **Conseguenze** - Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo avanti rispetto alla realtà, dove non partiva mai. -- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`, - perché `push-messaggio` continua a costruire il testo della giornata per le push senza coda. +- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`. - Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun ambiente. **Riesame** Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron torna utile, e con lui il segreto: DD-024 descrive già come farlo. + +--- + +### DD-026 — Il testo della notifica viaggia dentro la push + +**Stato:** accettata · **Data:** settembre 2026 + +**Contesto** +Le push partivano senza payload: il service worker, appena svegliato, chiedeva a +`/api/public/push-messaggio` che cosa mostrare. Ad app aperta funzionava; ad app chiusa e +telefono bloccato non arrivava niente. È lo scenario per cui il browser dà al worker pochi +secondi di vita: una fetch verso un endpoint che interroga Supabase è esattamente ciò che non +riesce a chiudersi in tempo, e senza `showNotification` non compare nulla. `Urgency: high` e +un timeout di 3 secondi sul recupero del testo avevano attenuato il sintomo, non la causa. + +**Decisione** +Titolo e testo viaggiano cifrati nel corpo della push (`aes128gcm`, RFC 8188/8291) con le +chiavi `p256dh` e `auth` già salvate in `push_subscriptions`. Il service worker legge +`event.data.json()` e mostra la notifica: zero rete, zero attesa. + +Cadono di conseguenza la route `push-messaggio`, la coda `promemoria_push` (con la sua +scadenza a 12 ore), `messaggioPalloniOggi()` e il timeout nel service worker: tutti pezzi che +esistevano solo per rimediare al payload vuoto. Tutti e tre i mittenti +(`apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`) avevano già il testo pronto +prima di inviare. + +**Alternative scartate** + +- Usare la libreria `web-push` → dipende da `node:crypto`, e il build nitro ha come target + Cloudflare. La cifratura sta in ~40 righe di Web Crypto, già disponibile ovunque. +- Allungare ancora il timeout della fetch → allunga anche il tempo in cui il worker può + morire prima di mostrare qualcosa. Il problema era la fetch, non la sua durata. + +**Conseguenze** + +- La notifica non dipende più dalla rete al momento del risveglio, e nemmeno da una funzione + serverless che risponda in fretta a freddo. +- Sparisce l'unico punto in cui chiunque conoscesse un endpoint push poteva leggere il + messaggio destinato a quel dispositivo. +- Il payload sta sotto i 4 KB: i testi attuali sono ampiamente dentro, ma un messaggio molto + lungo andrebbe accorciato. +- La tabella `promemoria_push` resta nel database, ora inutilizzata: va rimossa con una + migrazione quando si tocca lo schema. + +**Riesame** +Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push +smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard). diff --git a/docs/TODO.md b/docs/TODO.md index c521fd7..767ce33 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,6 +5,9 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ ## In corso +- Correzione push Android pronta in locale: aggiornamento del worker nelle sessioni della + webapp e test del canale cifrato verificati. Resta la verifica su Motorola installato, + ad app chiusa e schermo bloccato; procedura e limiti in [Notifiche](modules/notifiche.md). - Documentazione tecnica del progetto. - Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index d5c2cb6..24feacb 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -3,7 +3,7 @@ **Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push **File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`, -`src/routes/api/public/push-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`, +`src/routes/api/public/push-subscribe.ts`, `src/routes/api/public/push-prova.ts`, `public/push-sw.js` --- @@ -21,9 +21,9 @@ indipendenti: ## Dati -`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda -"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico -persistente: la riga viene eliminata non appena letta dal service worker). +`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e +`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è +più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026). --- @@ -40,24 +40,45 @@ smart in app, che usano lo stesso service worker. 4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions` (upsert). +All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione +push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso, +non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le +notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore. +Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si +riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione. +Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita +copre in particolare le sessioni lunghe della webapp (vedi il +[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)). + --- ## Ruolo delle tre route pubbliche - **`push-config`** — espone la sola chiave pubblica VAPID. - **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo. -- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: mette in coda su - `promemoria_push` l'avviso di apertura del sondaggio pre-partita per **tutti** i dispositivi - iscritti e manda la push (vedi [Scout Live](scout-live.md)). -- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della - ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per - sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push` - (scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio - calcolato al volo sul turno palloni (vedi [Palloni](palloni.md)). +- **`push-prova`** — manda una push al dispositivo che la chiede e riporta stato e corpo + della risposta del servizio push, più se l'endpoint risulta in `push_subscriptions`. Serve + a rendere osservabile un "non arriva": senza, ogni prova richiede un admin, un evento nello + stato giusto e una seconda persona. Il pulsante sta in Profilo → Opzioni, sotto + l'interruttore, e compare solo a notifiche attive. +- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i + dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi + [Scout Live](scout-live.md)). L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID -(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da -`sollecita-presenze.ts` e `promemoria-palloni.ts`. +(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST +all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`, +`promemoria-palloni.ts` e `apri-sondaggio.ts`. + +Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del +dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la +rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per +pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di +`showNotification` (DD-026). + +La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio +energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il +dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta. ### Chi può farle partire (DD-024, DD-025) @@ -66,10 +87,10 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna variabile d'ambiente. -| Route | Controllo | Chi la chiama | -| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin | -| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno | +| Route | Controllo | Chi la chiama | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin | +| `csi`, `push-config`, `push-subscribe`, `push-prova` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | --- @@ -87,34 +108,40 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. - Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo è iscritto o no. Separare i canali richiederebbe schema e UI dedicati. -- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si - autocancella alla lettura: non conserva nulla. Un messaggio in coda **scade dopo 12 ore** - (`ORE_VALIDITA_PROMEMORIA`): la riga si cancella comunque alla prima lettura, ma se è - vecchia il testo non viene mostrato e si ripiega su quello calcolato. Serve perché la coda - si svuota solo quando il dispositivo legge, e se la push non arriva mai la riga resterebbe - a dirottare la notifica successiva, di qualunque tipo, giorni dopo. +- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va + eliminata con una migrazione alla prossima occasione. - **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire `push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N server push", non come "arrivate a N telefoni". -- Nessuna verifica di autenticazione su `push-messaggio`: chiunque conosca un endpoint push - valido può leggerne il messaggio. Non è chiudibile con un segreto, perché a chiamarla è il - service worker, dove qualsiasi segreto sarebbe pubblico; di fatto la protegge il dover - conoscere l'endpoint, che è un URL segreto per dispositivo. `promemoria-palloni` invece è - chiusa da DD-024. - Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato): serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala - esplicitamente. + esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone. +- **Android con l'app installata (WebAPK) è il caso fragile.** A parità di server — stessa + push, stesso payload — su iPhone installato da Home arriva ad app chiusa, sullo stesso + invio verso un Android installato come webapp no. Il WebAPK è un'app Android a sé: ha un + proprio permesso notifiche di sistema. Le restrizioni di sistema possono impedire la + visualizzazione o ritardare il risveglio del browser: un 201 dal servizio push non permette + di distinguerli. Controllare Impostazioni → App → **CrAPP** → Notifiche e le eventuali + restrizioni di batteria dell'app e del browser. +- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e, + dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore + documenta la limitazione dei processi in background + ([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)). + È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il + codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro + dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di + prova invia subito, quindi da solo non dimostra la ricezione in background. - Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica locale non viene mai mostrata, anche con permesso concesso. -- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`) - per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio. +- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio + molto lungo verrebbe rifiutato dal servizio push. --- ## Evoluzioni possibili - Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra. -- Aggiungere autenticazione alle route pubbliche coinvolte. +- Eliminare `promemoria_push` con una migrazione. - Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home). diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index da9a22c..0b6bca3 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -48,10 +48,8 @@ e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso per ciascuno. -Il testo va messo in coda su `promemoria_push` prima di inviare la push, perché la push parte -"vuota" e il service worker chiede a `/api/public/push-messaggio` cosa mostrare — e quella -route da sola sa raccontare solo la giornata corrente, quindi un avviso mandato con giorni di -anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio` (vedi +Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza +nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi [Notifiche](notifiche.md)). --- @@ -60,8 +58,7 @@ anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio - **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di - turno oggi" — resta in `palloni-core.ts` perché la usa `push-messaggio` per il testo - calcolato al volo, ma nessuno scheduler la interroga. + turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno. - Il conteggio dei turni include anche le proposte non confermate: badge e statistiche possono contare turni mai effettivamente convalidati da nessuno. - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto diff --git a/docs/modules/presenze.md b/docs/modules/presenze.md index 9c99c7c..0c446e4 100644 --- a/docs/modules/presenze.md +++ b/docs/modules/presenze.md @@ -54,7 +54,7 @@ Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze src/routes/api/public/sollecita-presenze.ts ├─ legge l'evento (eventi_app) e le risposte già date ├─ calcola i destinatari: giocatori attivi senza risposta o con "forse" - ├─ per ciascuno registra il messaggio in promemoria_push e invia una push + ├─ per ciascuno invia una push col testo cifrato nel payload │ (src/lib/webpush.server.ts) └─ elimina le iscrizioni push scadute (404/410) ``` diff --git a/public/push-sw.js b/public/push-sw.js index fcd8f0a..8e896be 100644 --- a/public/push-sw.js +++ b/public/push-sw.js @@ -1,36 +1,31 @@ -// Service worker dedicato alle notifiche push del turno palloni. +// Service worker dedicato alle notifiche push di CrAPP. // Non memorizza nella cache pagine o asset dell'app. -self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting())); self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); -async function testoNotifica() { - try { - const sub = await self.registration.pushManager.getSubscription(); - if (!sub) return null; - const res = await fetch("/api/public/push-messaggio", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: sub.endpoint }), - }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } -} - self.addEventListener("push", (event) => { + // Il testo arriva cifrato dentro la push: nessuna rete, nessuna attesa. A dispositivo + // dormiente il browser sveglia il worker per pochi secondi e una fetch di troppo lo fa + // terminare prima di `showNotification` — la notifica non appare affatto, mentre ad app + // aperta, con la rete calda, sembra funzionare tutto. + let dati = null; + try { + dati = event.data?.json() ?? null; + } catch { + dati = null; + } event.waitUntil( - (async () => { - const dati = await testoNotifica(); - await self.registration.showNotification(dati?.title ?? "CrAPP · Turno palloni", { - body: dati?.body ?? "Controlla il turno palloni di oggi.", - icon: "/icon-192.png", - badge: "/icon-192.png", - tag: "turno-palloni", - }); - })(), + self.registration.showNotification(dati?.title ?? "CrAPP", { + body: dati?.body ?? "Apri l'app per i dettagli.", + icon: "/icon-192.png", + badge: "/icon-192.png", + // Il tag fa sostituire la notifica precedente invece di accumularne una pila; + // `renotify` fa sì che la sostituzione avvisi di nuovo, altrimenti la seconda + // notifica comparirebbe in silenzio e sembrerebbe non essere mai arrivata. + tag: "crapp-notifica", + renotify: true, + }), ); }); diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts index 8335337..4cb795f 100644 --- a/src/lib/palloni-core.ts +++ b/src/lib/palloni-core.ts @@ -107,47 +107,12 @@ export function destinatariPromemoriaPalloni( return [...destinatari]; } -/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */ -export function messaggioPalloniOggi( - turni: Record, - eventi: Evento[], - oggi: string, - mioId: string, - nome: string, -): { title: string; body: string } { - for (const evento of eventiDelGiorno(eventi, oggi)) { - const prima = eventoPrecedente(eventi, evento.id); - if (prima && turni[prima.id] === mioId) { - return { - title: "Porta i palloni oggi", - body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`, - }; - } - if (turni[evento.id] === mioId) { - const dopo = eventoSuccessivo(eventi, evento.id); - return { - title: "Tocca a te prendere i palloni", - body: dopo - ? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.` - : `A fine ${evento.titolo} porta a casa i palloni.`, - }; - } - } - - return { - title: "CrAPP · Turno palloni", - body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", - }; -} - /** * Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025). * * Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin * sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi". - * Il testo viaggia in coda su `promemoria_push` perché la push parte vuota e il service - * worker chiede a `push-messaggio` cosa mostrare — che da solo saprebbe raccontare solo - * la giornata corrente. + * Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete. */ export function avvisiPalloniEvento( turni: Record, diff --git a/src/lib/push-client.ts b/src/lib/push-client.ts index b8adad5..db6393e 100644 --- a/src/lib/push-client.ts +++ b/src/lib/push-client.ts @@ -21,6 +21,30 @@ export function pushSupportato() { ); } +/** Aggiorna il worker già installato anche nelle sessioni lunghe della webapp. */ +export function mantieniWorkerPushAggiornato(): () => void { + if (!pushSupportato()) return () => {}; + + let inCorso = false; + const aggiorna = async () => { + if (document.visibilityState !== "visible" || inCorso) return; + inCorso = true; + try { + const reg = await navigator.serviceWorker.getRegistration("/push-sw.js"); + // Non registrare né iscrivere chi non ha mai attivato le notifiche. + await reg?.update(); + } catch { + // Offline: il worker attivo resta valido. Si riprova al prossimo ritorno nell'app. + } finally { + inCorso = false; + } + }; + + void aggiorna(); + document.addEventListener("visibilitychange", aggiorna); + return () => document.removeEventListener("visibilitychange", aggiorna); +} + export async function statoNotifiche(): Promise { if (!pushSupportato()) return false; const reg = await navigator.serviceWorker.getRegistration("/push-sw.js"); @@ -56,6 +80,32 @@ export async function attivaNotifiche(giocatoreId: string): Promise { if (!res.ok) throw new Error("Salvataggio iscrizione non riuscito"); } +/** + * Manda una push di prova a questo dispositivo e racconta com'è andata. + * + * Esiste perché "non arriva" è un sintomo cieco: senza, ogni prova richiede un admin, un + * evento nello stato giusto e una seconda persona. L'invio è immediato: per verificare + * l'arrivo ad app chiusa serve invece un invio da un altro dispositivo. + */ +export async function notificaDiProva(): Promise<{ + nelDatabase: boolean; + stato: number; + corpo: string; +}> { + if (!pushSupportato()) throw new Error("Notifiche non supportate su questo dispositivo"); + const reg = await navigator.serviceWorker.getRegistration("/push-sw.js"); + const sub = await reg?.pushManager.getSubscription(); + if (!sub) throw new Error("Notifiche non attive su questo dispositivo"); + + const res = await fetch("/api/public/push-prova", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + if (!res.ok) throw new Error("Prova non riuscita"); + return (await res.json()) as { nelDatabase: boolean; stato: number; corpo: string }; +} + export async function disattivaNotifiche(): Promise { if (!pushSupportato()) return; const reg = await navigator.serviceWorker.getRegistration("/push-sw.js"); diff --git a/src/lib/webpush.server.ts b/src/lib/webpush.server.ts index 50fe9b9..406d9c7 100644 --- a/src/lib/webpush.server.ts +++ b/src/lib/webpush.server.ts @@ -1,4 +1,4 @@ -/** Invio di notifiche web push (senza payload) firmate con VAPID, compatibile con il runtime edge. */ +/** Invio di notifiche web push con payload cifrato (RFC 8291), firmate con VAPID, compatibile con il runtime edge. */ function base64UrlDecode(value: string): Uint8Array { const padding = "=".repeat((4 - (value.length % 4)) % 4); @@ -17,6 +17,16 @@ function base64UrlEncode(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +function concat(...parti: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const parte of parti) { + out.set(parte, offset); + offset += parte.length; + } + return out; +} + function encodeJson(value: unknown): string { return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value))); } @@ -60,40 +70,121 @@ async function creaVapidJwt( return `${header}.${payload}.${base64UrlEncode(new Uint8Array(firma))}`; } -/** - * Per quante ore un messaggio in coda su `promemoria_push` resta un promemoria. - * - * La coda si svuota solo quando il dispositivo legge il messaggio, e se la push non arriva - * mai (registrazione scaduta: i server push accettano con 2xx e poi buttano via) la riga - * resta lì. Senza una scadenza, la notifica successiva — di qualunque tipo — mostrerebbe - * un sollecito di giorni prima, per un evento ormai passato. - */ -export const ORE_VALIDITA_PROMEMORIA = 12; +/** Un dispositivo iscritto: endpoint del servizio push e chiavi con cui cifrare per lui. */ +export type IscrizionePush = { endpoint: string; p256dh: string; auth: string }; -/** Un promemoria accodato è ancora attuale? */ -export function promemoriaAncoraValido(creatoIl: string, adesso: Date = new Date()): boolean { - const creato = Date.parse(creatoIl); - if (Number.isNaN(creato)) return false; - return adesso.getTime() - creato < ORE_VALIDITA_PROMEMORIA * 60 * 60 * 1000; +async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) { + const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [ + "deriveBits", + ]); + const bits = await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource }, + chiave, + byte * 8, + ); + return new Uint8Array(bits); } -/** Invia una notifica "vuota": il service worker recupera poi il testo aggiornato. */ -export async function inviaPush(endpoint: string): Promise { +function etichetta(testo: string): Uint8Array { + return concat(new TextEncoder().encode(testo), new Uint8Array([0])); +} + +/** + * Cifra il testo della notifica nel corpo della push, in formato `aes128gcm`. + * + * Esportata solo per i test: il payload viaggia dentro la push proprio perché il service + * worker non abbia bisogno della rete per sapere cosa mostrare — a dispositivo dormiente + * il browser lo sveglia per pochi secondi e una fetch di troppo lo fa morire prima di + * `showNotification`, cioè niente notifica ad app chiusa. + */ +export async function cifraPayload( + iscrizione: IscrizionePush, + testoInChiaro: string, +): Promise { + const uaPublic = base64UrlDecode(iscrizione.p256dh); + const authSecret = base64UrlDecode(iscrizione.auth); + const salt = crypto.getRandomValues(new Uint8Array(16)); + + const effimera = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); + const asPublic = new Uint8Array(await crypto.subtle.exportKey("raw", effimera.publicKey)); + const uaKey = await crypto.subtle.importKey( + "raw", + uaPublic as BufferSource, + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const condiviso = new Uint8Array( + await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey }, effimera.privateKey, 256), + ); + + const ikm = await hkdf( + authSecret, + condiviso, + concat(etichetta("WebPush: info"), uaPublic, asPublic), + 32, + ); + const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16); + const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12); + + // Il delimitatore 0x02 chiude l'ultimo (e unico) record: il testo sta sotto i 4 KB. + const chiaro = concat(new TextEncoder().encode(testoInChiaro), new Uint8Array([2])); + const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [ + "encrypt", + ]); + const cifrato = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce as BufferSource }, + aes, + chiaro as BufferSource, + ), + ); + + // Intestazione RFC 8188: salt(16) | record size(4) | lunghezza chiave(1) | chiave(65). + const dimensioneRecord = new Uint8Array(4); + new DataView(dimensioneRecord.buffer).setUint32(0, 4096); + return concat(salt, dimensioneRecord, new Uint8Array([asPublic.length]), asPublic, cifrato); +} + +/** + * Invia la notifica: titolo e testo viaggiano cifrati dentro la push. + * + * Torna anche il corpo della risposta, non solo lo stato: un servizio push che rifiuta + * spiega il perché lì dentro, e buttarlo via lasciava i guasti indistinguibili fra loro. + */ +export async function inviaPush( + iscrizione: IscrizionePush, + titolo: string, + testo: string, +): Promise<{ stato: number; corpo: string }> { const publicKey = process.env["VAPID_PUBLIC_KEY"]; const privateKey = process.env["VAPID_PRIVATE_KEY"]; const subject = process.env["VAPID_SUBJECT"] ?? "mailto:crapp@crapvolley.it"; if (!publicKey || !privateKey) throw new Error("Chiavi VAPID non configurate"); - const audience = new URL(endpoint).origin; + const audience = new URL(iscrizione.endpoint).origin; const jwt = await creaVapidJwt(audience, subject, publicKey, privateKey); + const corpo = await cifraPayload(iscrizione, JSON.stringify({ title: titolo, body: testo })); - const res = await fetch(endpoint, { + const res = await fetch(iscrizione.endpoint, { method: "POST", headers: { TTL: "86400", + // Senza `Urgency` il servizio push usa "normal", e un telefono in risparmio + // energetico (doze) accumula i messaggi normali fino al risveglio: la notifica + // arriva solo quando il dispositivo è già attivo, cioè quando l'app è aperta. + // "high" chiede la consegna immediata anche a schermo spento (RFC 8030 §5.3). + Urgency: "high", Authorization: `vapid t=${jwt}, k=${publicKey}`, - "Content-Length": "0", + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", }, + body: corpo as BodyInit, }); - return res.status; + + const risposta = (await res.text().catch(() => "")).slice(0, 500); + if (!res.ok) console.error("inviaPush", res.status, risposta); + return { stato: res.status, corpo: risposta }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dad9b15..83d5887 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,7 +24,7 @@ import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/ import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' -import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio' +import { Route as ApiPublicPushProvaRouteImport } from './routes/api/public/push-prova' import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe' import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze' @@ -104,9 +104,9 @@ const ApiPublicPushConfigRoute = ApiPublicPushConfigRouteImport.update({ path: '/api/public/push-config', getParentRoute: () => rootRouteImport, } as any) -const ApiPublicPushMessaggioRoute = ApiPublicPushMessaggioRouteImport.update({ - id: '/api/public/push-messaggio', - path: '/api/public/push-messaggio', +const ApiPublicPushProvaRoute = ApiPublicPushProvaRouteImport.update({ + id: '/api/public/push-prova', + path: '/api/public/push-prova', getParentRoute: () => rootRouteImport, } as any) const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({ @@ -137,7 +137,7 @@ export interface FileRoutesByFullPath { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute + '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -157,7 +157,7 @@ export interface FileRoutesByTo { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute + '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -178,7 +178,7 @@ export interface FileRoutesById { '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute - '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute + '/api/public/push-prova': typeof ApiPublicPushProvaRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute } @@ -200,7 +200,7 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-messaggio' + | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' fileRoutesByTo: FileRoutesByTo @@ -220,7 +220,7 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-messaggio' + | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' id: @@ -240,7 +240,7 @@ export interface FileRouteTypes { | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' - | '/api/public/push-messaggio' + | '/api/public/push-prova' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' fileRoutesById: FileRoutesById @@ -261,7 +261,7 @@ export interface RootRouteChildren { ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute - ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute + ApiPublicPushProvaRoute: typeof ApiPublicPushProvaRoute ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute } @@ -373,11 +373,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicPushConfigRouteImport parentRoute: typeof rootRouteImport } - '/api/public/push-messaggio': { - id: '/api/public/push-messaggio' - path: '/api/public/push-messaggio' - fullPath: '/api/public/push-messaggio' - preLoaderRoute: typeof ApiPublicPushMessaggioRouteImport + '/api/public/push-prova': { + id: '/api/public/push-prova' + path: '/api/public/push-prova' + fullPath: '/api/public/push-prova' + preLoaderRoute: typeof ApiPublicPushProvaRouteImport parentRoute: typeof rootRouteImport } '/api/public/push-subscribe': { @@ -413,7 +413,7 @@ const rootRouteChildren: RootRouteChildren = { ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, - ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute, + ApiPublicPushProvaRoute: ApiPublicPushProvaRoute, ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute, ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index fe38389..6c37d79 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { Toaster } from "../components/ui/sonner"; import { TeamLogo } from "../components/crapp/ui-bits"; import { useGiocatoreBase } from "../lib/user-store"; import { useSessione } from "../lib/auth"; +import { mantieniWorkerPushAggiornato } from "../lib/push-client"; function NotFoundComponent() { return ( @@ -175,6 +176,8 @@ function AppShell() { const [mounted, setMounted] = useState(false); const isBenvenuto = location.pathname === "/benvenuto"; + useEffect(mantieniWorkerPushAggiornato, []); + // Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome // (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori // chi ha già la sessione in localStorage. diff --git a/src/routes/api/public/apri-sondaggio.ts b/src/routes/api/public/apri-sondaggio.ts index bfefa40..0ea2b89 100644 --- a/src/routes/api/public/apri-sondaggio.ts +++ b/src/routes/api/public/apri-sondaggio.ts @@ -24,7 +24,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { data: iscrizioni } = await supabaseAdmin .from("push_subscriptions") - .select("endpoint"); + .select("endpoint, p256dh, auth"); const titolo = "💩 Sondaggio pre-partita aperto"; const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`; @@ -32,10 +32,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ let inviate = 0; for (const iscrizione of iscrizioni ?? []) { try { - await supabaseAdmin - .from("promemoria_push") - .insert({ endpoint: iscrizione.endpoint, titolo, testo }); - const stato = await inviaPush(iscrizione.endpoint); + const { stato } = await inviaPush(iscrizione, titolo, testo); if (stato === 404 || stato === 410) { await supabaseAdmin .from("push_subscriptions") diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts index 2347d70..97a318c 100644 --- a/src/routes/api/public/promemoria-palloni.ts +++ b/src/routes/api/public/promemoria-palloni.ts @@ -11,8 +11,7 @@ const schema = z.object({ eventoId: z.string().min(1).max(50) }); /** * Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina - * dell'evento (DD-025). Il testo va in coda su `promemoria_push` perché la push parte - * vuota e il service worker chiede a `push-messaggio` cosa mostrare. + * dell'evento (DD-025). Il testo viaggia cifrato dentro la push. */ export const Route = createFileRoute("/api/public/promemoria-palloni")({ server: { @@ -47,7 +46,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ const { data: iscrizioni } = await supabaseAdmin .from("push_subscriptions") - .select("endpoint, giocatore_id") + .select("endpoint, giocatore_id, p256dh, auth") .in( "giocatore_id", avvisi.map((a) => a.giocatoreId), @@ -58,12 +57,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id); if (!avviso) continue; try { - await supabaseAdmin.from("promemoria_push").insert({ - endpoint: iscrizione.endpoint, - titolo: avviso.titolo, - testo: avviso.testo, - }); - const stato = await inviaPush(iscrizione.endpoint); + const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo); if (stato === 404 || stato === 410) { await supabaseAdmin .from("push_subscriptions") diff --git a/src/routes/api/public/push-messaggio.ts b/src/routes/api/public/push-messaggio.ts deleted file mode 100644 index 17c39bb..0000000 --- a/src/routes/api/public/push-messaggio.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; -import { nomeCompleto } from "@/lib/giocatori-squadra"; -import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; -import { completaTurni, messaggioPalloniOggi, oggiISO } from "@/lib/palloni-core"; -import { leggiEventi } from "@/lib/eventi.server"; -import { promemoriaAncoraValido } from "@/lib/webpush.server"; - -const schema = z.object({ endpoint: z.string().url().max(1000) }); - -export const Route = createFileRoute("/api/public/push-messaggio")({ - server: { - handlers: { - POST: async ({ request }) => { - const parsed = schema.safeParse(await request.json()); - if (!parsed.success) return new Response("Dati non validi", { status: 400 }); - - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - - // Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato. - const { data: promemoria } = await supabaseAdmin - .from("promemoria_push") - .select("id, titolo, testo, creato_il") - .eq("endpoint", parsed.data.endpoint) - .order("creato_il", { ascending: false }) - .limit(1) - .maybeSingle(); - if (promemoria) { - // La coda si svuota comunque, anche quando il messaggio è vecchio: altrimenti - // resterebbe lì a dirottare la prossima notifica di qualunque tipo. - await supabaseAdmin.from("promemoria_push").delete().eq("endpoint", parsed.data.endpoint); - if (promemoriaAncoraValido(promemoria.creato_il)) { - return Response.json({ title: promemoria.titolo, body: promemoria.testo }); - } - } - - const { data: iscrizione } = await supabaseAdmin - .from("push_subscriptions") - .select("giocatore_id") - .eq("endpoint", parsed.data.endpoint) - .maybeSingle(); - - if (!iscrizione) - return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." }); - - const { data: righe } = await supabaseAdmin - .from("turni_palloni") - .select("evento_id, giocatore_id"); - const salvati: Record = {}; - for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; - const eventi = await leggiEventi(); - const squadra = await leggiGiocatoriSquadra(); - const rosa = squadra - .filter((g) => g.attivo) - .map((g) => ({ id: g.id, nome: nomeCompleto(g) })); - const turni = completaTurni(salvati, eventi, rosa); - - const oggi = oggiISO(); - const mioId = iscrizione.giocatore_id; - const giocatore = squadra.find((g) => g.id === mioId); - const nome = giocatore ? nomeCompleto(giocatore) : ""; - - return Response.json(messaggioPalloniOggi(turni, eventi, oggi, mioId, nome)); - }, - }, - }, -}); diff --git a/src/routes/api/public/push-prova.ts b/src/routes/api/public/push-prova.ts new file mode 100644 index 0000000..668721f --- /dev/null +++ b/src/routes/api/public/push-prova.ts @@ -0,0 +1,54 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { inviaPush } from "@/lib/webpush.server"; + +const schema = z.object({ endpoint: z.string().url().max(1000) }); + +/** + * Manda una push di prova al dispositivo che la chiede, e racconta com'è andata. + * + * Serve a rendere osservabile un guasto che finora si vedeva solo come "non arriva": + * senza questa route ogni prova richiede un admin, un evento nello stato giusto e una + * seconda persona, e la risposta del servizio push viene buttata via. Qui invece si vede + * lo stato HTTP, il corpo della risposta e se l'endpoint è davvero quello registrato in + * `push_subscriptions` — cioè se il server sta parlando con questo telefono o con una + * vecchia iscrizione morta. + * + * Nessun controllo di accesso, come per `push-config` e `push-subscribe`: manda solo + * all'endpoint che il chiamante fornisce, quindi al massimo si sveglia da solo. È un URL + * segreto per dispositivo, e chi lo conosce lo ha già. + */ +export const Route = createFileRoute("/api/public/push-prova")({ + server: { + handlers: { + POST: async ({ request }) => { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) return new Response("Dati non validi", { status: 400 }); + + const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); + const { data: iscrizione } = await supabaseAdmin + .from("push_subscriptions") + .select("endpoint, p256dh, auth") + .eq("endpoint", parsed.data.endpoint) + .maybeSingle(); + + if (!iscrizione) { + // Il dispositivo ha una sottoscrizione che il database non conosce: la push non + // partirebbe mai da sola, perché i mittenti leggono solo da qui. + return Response.json({ + nelDatabase: false, + stato: 0, + corpo: "Questo dispositivo non risulta iscritto: riattiva le notifiche.", + }); + } + + const { stato, corpo } = await inviaPush( + iscrizione, + "🔔 Notifica di prova", + `Inviata alle ${new Date().toLocaleTimeString("it-IT")}. Se la leggi ad app chiusa, il canale funziona.`, + ); + return Response.json({ nelDatabase: true, stato, corpo }); + }, + }, + }, +}); diff --git a/src/routes/api/public/sollecita-presenze.ts b/src/routes/api/public/sollecita-presenze.ts index c5f0804..8a0ad07 100644 --- a/src/routes/api/public/sollecita-presenze.ts +++ b/src/routes/api/public/sollecita-presenze.ts @@ -46,7 +46,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({ const { data: iscrizioni } = await supabaseAdmin .from("push_subscriptions") - .select("endpoint, giocatore_id") + .select("endpoint, p256dh, auth") .in("giocatore_id", destinatari); const titolo = "Manca la tua risposta"; @@ -57,10 +57,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({ let inviate = 0; for (const iscrizione of iscrizioni ?? []) { try { - await supabaseAdmin - .from("promemoria_push") - .insert({ endpoint: iscrizione.endpoint, titolo, testo }); - const stato = await inviaPush(iscrizione.endpoint); + const { stato } = await inviaPush(iscrizione, titolo, testo); if (stato === 404 || stato === 410) { await supabaseAdmin .from("push_subscriptions") diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 8499f41..3f582df 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -1,7 +1,17 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; +import { + Flame, + Camera, + Trash2, + Bell, + BellRing, + LogOut, + ShieldCheck, + Bug, + Lightbulb, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; @@ -21,6 +31,7 @@ import { usePresenzeUltimoMese } from "@/lib/presenze-mese"; import { attivaNotifiche, disattivaNotifiche, + notificaDiProva, pushSupportato, statoNotifiche, } from "@/lib/push-client"; @@ -72,6 +83,7 @@ function Profilo() { const [bust, setBust] = useState(0); const [notifiche, setNotifiche] = useState(false); const [inCorso, setInCorso] = useState(false); + const [inProva, setInProva] = useState(false); const [supportate, setSupportate] = useState(true); useEffect(() => { @@ -103,6 +115,22 @@ function Profilo() { } } + async function provaNotifica() { + if (inProva) return; + setInProva(true); + try { + const esito = await notificaDiProva(); + if (!esito.nelDatabase) toast.error(esito.corpo); + else if (esito.stato >= 200 && esito.stato < 300) + toast.success("Push accettata dal servizio: verifica la notifica sul dispositivo"); + else toast.error(`Il servizio push ha risposto ${esito.stato}: ${esito.corpo}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Prova non riuscita"); + } finally { + setInProva(false); + } + } + async function logout() { try { await esci(); @@ -271,6 +299,24 @@ function Profilo() { + {notifiche ? ( + + ) : null} {admin ? ( { const sw = await (await fetch(url("/push-sw.js"))).text(); - assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker"); + // DD-026: ora il worker riceve il testo cifrato, senza recuperarlo dalla rete. + assert.match(sw, /event\.data\?\.json\(\)/, "legge il payload della push"); assert.match(sw, /showNotification/, "il worker mostra la notifica"); + assert.doesNotMatch( + sw, + /\bfetch\s*\(|\/api\/public\/push-messaggio/, + "mostrare la push non deve richiedere rete ad app chiusa", + ); const config = await fetch(url("/api/public/push-config")); assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile"); }); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index a34e5e8..81cda51 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -109,8 +109,15 @@ try { assert.equal(res.status, 400, "anche la cancellazione valida l'input"); }); - await prova("push-messaggio rifiuta i payload non validi", async () => { - assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400); + // DD-026: il testo è nel payload; la vecchia route non deve più essere esposta. + await prova("push-messaggio è stata rimossa", async () => { + assert.equal((await postJson("/api/public/push-messaggio", {})).status, 404); + }); + + await prova("push-prova rifiuta i payload non validi senza inviare notifiche", async () => { + for (const body of [{}, { endpoint: "non-un-url" }, { endpoint: "x".repeat(1001) }]) { + assert.equal((await postJson("/api/public/push-prova", body)).status, 400); + } }); // --- le route che mandano notifiche a tutti (DD-024) ------------------------ @@ -151,13 +158,15 @@ try { // --- endpoint che leggono dal database ------------------------------------- if (haSupabase()) { - await prova("push-messaggio risponde con il messaggio di default", async () => { - const res = await postJson("/api/public/push-messaggio", { + await prova("push-prova segnala un dispositivo non iscritto senza inviare", async () => { + const res = await postJson("/api/public/push-prova", { endpoint: `https://push.example/test-${Date.now()}`, }); assert.equal(res.status, 200); - const dati = (await json(res)) as { title?: string; body?: string }; - assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo"); + const dati = (await json(res)) as { nelDatabase?: boolean; stato?: number; corpo?: string }; + assert.equal(dati.nelDatabase, false); + assert.equal(dati.stato, 0, "nessuna chiamata al servizio push"); + assert.ok(dati.corpo, "spiega come riattivare le notifiche"); }); // La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404 diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts index 03c8603..16a4950 100644 --- a/test/unit/palloni-core.test.ts +++ b/test/unit/palloni-core.test.ts @@ -1,6 +1,6 @@ /** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */ import assert from "node:assert/strict"; -import { formatData, giocatori } from "@/lib/crapp-data"; +import { giocatori } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { completaTurni, @@ -11,7 +11,6 @@ import { eventiPalloni, eventoPrecedente, eventoSuccessivo, - messaggioPalloniOggi, oggiISO, } from "@/lib/palloni-core"; @@ -176,37 +175,4 @@ assert.deepEqual( "senza turni assegnati non c'è nessuno da avvisare", ); -// --- messaggioPalloniOggi ----------------------------------------------------- -assert.deepEqual( - messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"), - { - title: "Porta i palloni oggi", - body: "Evento p2 · 21:00. I palloni li hai tu dalla volta scorsa.", - }, - "chi li aveva alla volta prima deve riportarli oggi", -); -assert.deepEqual( - messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g2", "Luca"), - { - title: "Tocca a te prendere i palloni", - body: `A fine Evento p2 porta a casa i palloni e riportali il ${formatData("2026-02-03")}.`, - }, - "l'incaricato di oggi sa quando riportarli, se c'è un evento successivo", -); -assert.deepEqual( - messaggioPalloniOggi(turniPush, eventiPush, "2026-02-03", "g3", "Anna"), - { title: "Tocca a te prendere i palloni", body: "A fine Evento p3 porta a casa i palloni." }, - "senza evento successivo il messaggio non promette una data", -); -assert.deepEqual( - messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g9", "Sara"), - { title: "CrAPP · Turno palloni", body: "Sara, controlla il turno palloni nel calendario." }, - "chi non è coinvolto oggi riceve il messaggio generico col proprio nome", -); -assert.deepEqual( - messaggioPalloniOggi(turniPush, eventiPush, "2026-05-01", "g9", ""), - { title: "CrAPP · Turno palloni", body: "Controlla il calendario." }, - "senza eventi in quella data e senza nome noto, il messaggio resta generico", -); - console.log("palloni-core: ok"); diff --git a/test/unit/push-client.test.ts b/test/unit/push-client.test.ts index 731f379..b806c7c 100644 --- a/test/unit/push-client.test.ts +++ b/test/unit/push-client.test.ts @@ -7,6 +7,8 @@ import assert from "node:assert/strict"; import { attivaNotifiche, disattivaNotifiche, + mantieniWorkerPushAggiornato, + notificaDiProva, pushSupportato, statoNotifiche, } from "@/lib/push-client"; @@ -21,7 +23,113 @@ assert.equal(await statoNotifiche(), false); // --- attivaNotifiche: rifiuta subito, senza chiedere permessi o rete ---------- await assert.rejects(() => attivaNotifiche("g1"), /Notifiche non supportate su questo dispositivo/); +// --- notificaDiProva: rifiuta subito, senza toccare la rete ------------------- +await assert.rejects(() => notificaDiProva(), /Notifiche non supportate su questo dispositivo/); + // --- disattivaNotifiche: no-op silenzioso, nessun errore ---------------------- await assert.doesNotReject(() => disattivaNotifiche()); +// Il mantenimento del worker è innocuo anche durante SSR. +assert.doesNotThrow(mantieniWorkerPushAggiornato()); + +const originali = new Map( + ["window", "navigator", "document"].map((nome) => [ + nome, + Object.getOwnPropertyDescriptor(globalThis, nome), + ]), +); +const documento = Object.assign(new EventTarget(), { visibilityState: "visible" }); +let letture = 0; +let aggiornamenti = 0; +let presente = true; +let errore = false; +let sblocca: (() => void) | undefined; +let attesa: Promise | undefined; +let interrompi = () => {}; +const ciclo = () => new Promise((resolve) => setTimeout(resolve, 0)); +const visibilita = async (stato: string) => { + documento.visibilityState = stato; + documento.dispatchEvent(new Event("visibilitychange")); + await ciclo(); +}; + +try { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { PushManager: {}, Notification: {} }, + }); + Object.defineProperty(globalThis, "document", { configurable: true, value: documento }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + serviceWorker: { + async getRegistration(url: string) { + assert.equal(url, "/push-sw.js"); + letture++; + return presente + ? { + async update() { + aggiornamenti++; + if (errore) throw new Error("offline"); + await attesa; + }, + } + : undefined; + }, + // Un aggiornamento non deve cancellare o sostituire la registrazione. + register() { + assert.fail("non deve registrare un nuovo worker"); + }, + }, + }, + }); + + interrompi = mantieniWorkerPushAggiornato(); + await ciclo(); + assert.equal(aggiornamenti, 1, "aggiorna il worker esistente all'avvio"); + + await visibilita("hidden"); + assert.equal(letture, 1, "nessun lavoro quando l'app viene chiusa"); + await visibilita("visible"); + assert.equal(aggiornamenti, 2, "controlla gli aggiornamenti al ritorno nella webapp"); + + errore = true; + await visibilita("visible"); + assert.equal(aggiornamenti, 3, "un errore offline non blocca l'app"); + errore = false; + await visibilita("visible"); + assert.equal(aggiornamenti, 4, "riprova dopo l'errore"); + + attesa = new Promise((resolve) => (sblocca = resolve)); + await visibilita("visible"); + await visibilita("visible"); + assert.equal(aggiornamenti, 5, "accorpa gli aggiornamenti sovrapposti"); + sblocca!(); + await ciclo(); + attesa = undefined; + + presente = false; + await visibilita("visible"); + assert.equal(aggiornamenti, 5, "nessuna nuova iscrizione senza registrazione"); + + interrompi(); + const prima = letture; + await visibilita("visible"); + assert.equal(letture, prima, "rimuove il listener allo smontaggio"); + + documento.visibilityState = "hidden"; + presente = true; + interrompi = mantieniWorkerPushAggiornato(); + await ciclo(); + assert.equal(letture, prima, "non aggiorna se parte già in background"); + await visibilita("visible"); + assert.equal(aggiornamenti, 6); +} finally { + interrompi(); + for (const [nome, originale] of originali) { + if (originale) Object.defineProperty(globalThis, nome, originale); + else Reflect.deleteProperty(globalThis, nome); + } +} + console.log("push-client: ok"); diff --git a/test/unit/push-sw.test.ts b/test/unit/push-sw.test.ts new file mode 100644 index 0000000..c29c918 --- /dev/null +++ b/test/unit/push-sw.test.ts @@ -0,0 +1,82 @@ +/** Esegue il worker reale senza pagina, DOM, rete o stato lasciato dall'app. */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +const codice = readFileSync(new URL("../../public/push-sw.js", import.meta.url), "utf8"); +type Evento = { data?: { json: () => unknown } | null; waitUntil: (p: Promise) => void }; + +function avviaWorker() { + const listener = new Map void>(); + const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = []; + let completa: (() => void) | undefined; + const mostrata = new Promise((resolve) => (completa = resolve)); + const installata = Promise.resolve(); + const attivata = Promise.resolve(); + runInNewContext(codice, { + self: { + addEventListener: (nome: string, callback: (event: Evento) => void) => + listener.set(nome, callback), + skipWaiting: () => installata, + clients: { + claim: () => attivata, + matchAll: () => assert.fail("la push deve funzionare senza consultare finestre aperte"), + }, + registration: { + showNotification(titolo: string, opzioni: NotificationOptions) { + notifiche.push({ titolo, opzioni }); + return mostrata; + }, + }, + }, + fetch: () => assert.fail("nessuna rete per mostrare la notifica"), + }); + return { listener, notifiche, mostrata, completa: completa!, installata, attivata }; +} + +// L'installazione e l'attivazione tengono vivo il worker fino al completamento. +const avvio = avviaWorker(); +for (const [nome, promessa] of [ + ["install", avvio.installata], + ["activate", avvio.attivata], +] as const) { + let attesa: Promise | undefined; + avvio.listener.get(nome)!({ waitUntil: (p) => (attesa = p) }); + assert.equal(attesa, promessa, `${nome}: la promessa è collegata a waitUntil`); + await attesa; +} + +const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [ + { + data: { json: () => ({ title: "🏐 Palloni", body: "Sabato tocca a te." }) }, + titolo: "🏐 Palloni", + testo: "Sabato tocca a te.", + }, + { data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." }, + { + data: { + json: () => { + throw new SyntaxError("payload non JSON"); + }, + }, + titolo: "CrAPP", + testo: "Apri l'app per i dettagli.", + }, +]; +for (const { data, titolo, testo } of casi) { + // Nuovo contesto a ogni consegna: simula il risveglio dopo la terminazione del worker. + const worker = avviaWorker(); + let attesa: Promise | undefined; + worker.listener.get("push")!({ data, waitUntil: (p) => (attesa = p) }); + assert.equal(worker.notifiche.length, 1, "mostra subito una notifica anche senza l'app"); + assert.equal(attesa, worker.mostrata, "waitUntil copre tutta showNotification"); + const notifica = worker.notifiche[0]!; + assert.equal(notifica.titolo, titolo); + assert.equal(notifica.opzioni.body, testo); + assert.ok(notifica.opzioni.tag, "il tag permette di sostituire la notifica precedente"); + assert.equal(notifica.opzioni.renotify, true, "anche una sostituzione avvisa l'utente"); + worker.completa(); + await attesa; +} + +console.log("push-sw: ok"); diff --git a/test/unit/webpush-server.test.ts b/test/unit/webpush-server.test.ts index 03585b9..1faafe7 100644 --- a/test/unit/webpush-server.test.ts +++ b/test/unit/webpush-server.test.ts @@ -2,10 +2,12 @@ * Check dell'invio push server-side: `bun test/unit/webpush-server.test.ts`. * `inviaPush` firma un JWT VAPID con Web Crypto e fa una POST all'endpoint del * browser: qui generiamo una vera coppia di chiavi P-256 e sostituiamo `fetch` - * per intercettare la richiesta, così il test non tocca mai la rete. + * per intercettare la richiesta, così il test non tocca mai la rete. Il corpo + * cifrato viene decifrato con la chiave del "dispositivo": se la cifratura fosse + * sbagliata il browser scarterebbe la push in silenzio e non arriverebbe niente. */ import assert from "node:assert/strict"; -import { ORE_VALIDITA_PROMEMORIA, inviaPush, promemoriaAncoraValido } from "@/lib/webpush.server"; +import { inviaPush } from "@/lib/webpush.server"; function base64UrlEncode(bytes: Uint8Array): string { let binary = ""; @@ -13,6 +15,33 @@ function base64UrlEncode(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +function concat(...parti: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const parte of parti) { + out.set(parte, offset); + offset += parte.length; + } + return out; +} + +function etichetta(testo: string): Uint8Array { + return concat(new TextEncoder().encode(testo), new Uint8Array([0])); +} + +async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) { + const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [ + "deriveBits", + ]); + return new Uint8Array( + await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource }, + chiave, + byte * 8, + ), + ); +} + async function chiaviVapidDiProva() { const coppia = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ "sign", @@ -23,6 +52,64 @@ async function chiaviVapidDiProva() { return { publicKey: base64UrlEncode(raw), privateKey: jwk.d! }; } +/** Il "dispositivo": chiavi che finirebbero in `push_subscriptions`, più la privata. */ +async function dispositivoDiProva() { + const coppia = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [ + "deriveBits", + ]); + const pubblica = new Uint8Array(await crypto.subtle.exportKey("raw", coppia.publicKey)); + const auth = crypto.getRandomValues(new Uint8Array(16)); + return { + iscrizione: { + endpoint: "https://push.example/abc123", + p256dh: base64UrlEncode(pubblica), + auth: base64UrlEncode(auth), + }, + pubblica, + auth, + privata: coppia.privateKey, + }; +} + +/** Fa quello che fa il browser alla ricezione: apre il record aes128gcm (RFC 8188/8291). */ +async function decifra(corpo: Uint8Array, device: Awaited>) { + const salt = corpo.slice(0, 16); + const lunghezzaChiave = corpo[20]!; + const asPublic = corpo.slice(21, 21 + lunghezzaChiave); + const cifrato = corpo.slice(21 + lunghezzaChiave); + + const asKey = await crypto.subtle.importKey( + "raw", + asPublic as BufferSource, + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const condiviso = new Uint8Array( + await crypto.subtle.deriveBits({ name: "ECDH", public: asKey }, device.privata, 256), + ); + const ikm = await hkdf( + device.auth, + condiviso, + concat(etichetta("WebPush: info"), device.pubblica, asPublic), + 32, + ); + const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16); + const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12); + const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [ + "decrypt", + ]); + const chiaro = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: nonce as BufferSource }, + aes, + cifrato as BufferSource, + ), + ); + assert.equal(chiaro.at(-1), 2, "l'ultimo record finisce col delimitatore 0x02"); + return new TextDecoder().decode(chiaro.slice(0, -1)); +} + const originali = { pub: process.env["VAPID_PUBLIC_KEY"], priv: process.env["VAPID_PRIVATE_KEY"], @@ -40,11 +127,16 @@ function ripristinaEnv() { } } +const device = await dispositivoDiProva(); + try { // --- senza chiavi configurate: rifiuta subito, senza tentare la firma ------- delete process.env["VAPID_PUBLIC_KEY"]; delete process.env["VAPID_PRIVATE_KEY"]; - await assert.rejects(() => inviaPush("https://push.example/ep"), /Chiavi VAPID non configurate/); + await assert.rejects( + () => inviaPush(device.iscrizione, "t", "b"), + /Chiavi VAPID non configurate/, + ); // --- con le chiavi: firma il JWT e chiama fetch con l'header vapid ---------- const { publicKey, privateKey } = await chiaviVapidDiProva(); @@ -60,15 +152,25 @@ try { }) as typeof fetch; try { - const stato = await inviaPush("https://push.example/abc123"); + const { stato, corpo: risposta } = await inviaPush( + device.iscrizione, + "Porta i palloni", + "Stasera tocca a te.", + ); assert.equal(stato, 201, "restituisce lo status della risposta"); + assert.equal(typeof risposta, "string", "riporta anche il corpo, per capire i rifiuti"); assert.ok(richiesta, "ha chiamato fetch"); assert.equal(richiesta!.url, "https://push.example/abc123"); assert.equal(richiesta!.init.method, "POST"); const headers = new Headers(richiesta!.init.headers); assert.equal(headers.get("TTL"), "86400"); - assert.equal(headers.get("Content-Length"), "0"); + assert.equal(headers.get("Content-Encoding"), "aes128gcm"); + assert.equal( + headers.get("Urgency"), + "high", + "con l'urgenza normale un telefono in doze accumula i messaggi fino al risveglio: la notifica arriverebbe solo a dispositivo già attivo", + ); const auth = headers.get("Authorization")!; assert.ok(auth.startsWith("vapid t="), "usa lo schema vapid con il token JWT"); @@ -83,6 +185,15 @@ try { assert.equal(claim.aud, "https://push.example", "l'audience è l'origine dell'endpoint"); assert.equal(claim.sub, "mailto:test@example.com"); assert.ok(claim.exp > Date.now() / 1000, "il token scade nel futuro"); + + // --- il payload: il testo viaggia dentro la push, non lo si va a cercare -- + const corpo = new Uint8Array(richiesta!.init.body as ArrayBuffer); + assert.equal(corpo[20], 65, "l'intestazione dichiara la chiave effimera da 65 byte"); + assert.deepEqual( + JSON.parse(await decifra(corpo, device)), + { title: "Porta i palloni", body: "Stasera tocca a te." }, + "il dispositivo legge titolo e testo senza nessuna chiamata di rete", + ); } finally { globalThis.fetch = fetchOriginale; } @@ -90,24 +201,4 @@ try { ripristinaEnv(); } -// --- scadenza dei promemoria in coda ------------------------------------------ -// La coda si svuota solo quando il dispositivo legge: se la push non arriva mai, la -// riga resta. Senza scadenza dirotterebbe la notifica successiva, giorni dopo. -const ADESSO = new Date("2026-09-06T12:00:00Z"); -const oreFa = (n: number) => new Date(ADESSO.getTime() - n * 60 * 60 * 1000).toISOString(); - -assert.equal(promemoriaAncoraValido(oreFa(1), ADESSO), true, "un'ora fa è attuale"); -assert.equal( - promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA - 0.1), ADESSO), - true, - "poco prima della scadenza vale ancora", -); -assert.equal( - promemoriaAncoraValido(oreFa(ORE_VALIDITA_PROMEMORIA + 0.1), ADESSO), - false, - "oltre la scadenza non è più un promemoria", -); -assert.equal(promemoriaAncoraValido(oreFa(72), ADESSO), false, "un sollecito di tre giorni fa no"); -assert.equal(promemoriaAncoraValido("non-una-data", ADESSO), false, "una data illeggibile scade"); - console.log("webpush-server: ok");