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
+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");