Fa arrivare le push ad app chiusa e rende diagnosticabile quando non arrivano.

Il testo della notifica viaggia ora cifrato dentro la push (aes128gcm, RFC
8188/8291) invece di essere recuperato dal service worker con una fetch al
risveglio. Era quella fetch a non chiudersi in tempo: ad app chiusa il browser
tiene vivo il worker pochi secondi, showNotification non veniva mai chiamata e
non compariva niente, mentre ad app aperta con la rete calda sembrava tutto a
posto. La POST porta anche Urgency: high, che chiede la consegna immediata
invece di far accumulare i messaggi fino al risveglio del dispositivo.

Cadono i pezzi che esistevano solo per rimediare al payload vuoto: la route
push-messaggio, la coda promemoria_push con la sua scadenza a 12 ore,
messaggioPalloniOggi() e il timeout nel worker. Tutti e tre i mittenti avevano
gia il testo pronto prima di inviare.

Il worker si aggiorna da solo all'avvio e a ogni ritorno in primo piano
(mantieniWorkerPushAggiornato): nella webapp installata quello vecchio puo
sopravvivere a lungo, e senza questo un dispositivo resterebbe fermo alla
versione che va a cercare il testo in rete.

Profilo -> Opzioni ha "Mandami una notifica di prova", visibile solo a notifiche
attive: manda una push a questo dispositivo e riporta stato HTTP, corpo della
risposta e se l'endpoint risulta davvero in push_subscriptions. Senza, "non
arriva" era cieco: ogni prova richiedeva un admin, un evento nello stato giusto
e una seconda persona, e la risposta del servizio push veniva buttata via.
inviaPush torna { stato, corpo } e logga il corpo sui rifiuti.

Dalle prove sul campo: a parita di server, iPhone installato da Home riceve ad
app chiusa. Su Android installato come webapp resta da verificare: il WebAPK e
un'app Android a se, con permesso notifiche (Android 13+) e voce batteria
distinti da quelli di Chrome. Annotato nei limiti noti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 14:24:07 +02:00
co-authored by Claude Opus 5
parent 2b8ba340a6
commit d8f334007b
26 changed files with 782 additions and 306 deletions
+9
View File
@@ -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
+1 -1
View File
@@ -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
+57 -10
View File
@@ -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).
+3
View File
@@ -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
+60 -33
View File
@@ -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).
+3 -6
View File
@@ -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
+1 -1
View File
@@ -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)
```
+22 -27
View File
@@ -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,
}),
);
});
+1 -36
View File
@@ -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<string, string>,
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<string, string>,
+50
View File
@@ -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<boolean> {
if (!pushSupportato()) return false;
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
@@ -56,6 +80,32 @@ export async function attivaNotifiche(giocatoreId: string): Promise<void> {
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<void> {
if (!pushSupportato()) return;
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
+112 -21
View File
@@ -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 . 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<number> {
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<Uint8Array> {
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é 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 };
}
+17 -17
View File
@@ -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,
}
+3
View File
@@ -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.
+2 -5
View File
@@ -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")
+3 -9
View File
@@ -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")
-67
View File
@@ -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<string, string> = {};
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));
},
},
},
});
+54
View File
@@ -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 });
},
},
},
});
+2 -5
View File
@@ -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")
+47 -1
View File
@@ -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() {
<Bell className="h-4 w-4" />
</span>
</button>
{notifiche ? (
<button
type="button"
onClick={provaNotifica}
disabled={inProva}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Mandami una notifica di prova</span>
<span className="block text-xs text-muted-foreground">
Invia subito una push a questo dispositivo
</span>
</span>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-secondary text-muted-foreground">
<BellRing className="h-4 w-4" />
</span>
</button>
) : null}
{admin ? (
<Link
to="/admin"
+6
View File
@@ -15,6 +15,12 @@ bun test/unit/badges.test.ts # un singolo file
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
non può inquinare gli altri.
Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati,
senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`.
`unit/push-client.test.ts` verifica anche l'aggiornamento del worker all'avvio e al ritorno
in primo piano, inclusi errori offline e pulizia del listener. Sono test del codice:
la consegna effettiva a schermo bloccato richiede un telefono e il servizio push reale.
## Struttura
| Cartella | Cosa verifica | Serve rete? |
+7 -1
View File
@@ -96,8 +96,14 @@ try {
await prova("il service worker e la sua configurazione si parlano", async () => {
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");
});
+15 -6
View File
@@ -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
+1 -35
View File
@@ -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");
+108
View File
@@ -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<void> | 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<void>((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");
+82
View File
@@ -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<unknown>) => void };
function avviaWorker() {
const listener = new Map<string, (event: Evento) => void>();
const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = [];
let completa: (() => void) | undefined;
const mostrata = new Promise<void>((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<unknown> | 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<unknown> | 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");
+116 -25
View File
@@ -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<ReturnType<typeof dispositivoDiProva>>) {
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");