Push reminders: personal to the notes, generic without them

The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:

- Personal: "3 cards are due in Sky deck", in the app's language, tapping
  through to /review. Without notes (or with nothing due) it is the plain
  nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
  a path on this app, so a stolen subscription cannot make the notification
  link elsewhere. The store keeps endpoint, keys, the message and createdAt
  (a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
  opens and whenever the tab becomes visible (throttled to twice a day), so a
  count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
  PUSH_REMINDER_TITLE/_BODY for a subscription that has none
  (scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
  timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
  server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
  text in en and cs, the generic fallback in both, the URL guard, and the
  sender's preference order (81 checks).

Version 0.8.2, SW v104.
This commit is contained in:
Martin Dvořák 2026-10-18 14:34:15 +00:00
commit 77819b5961
18 changed files with 498 additions and 49 deletions

View file

@ -4,7 +4,7 @@
* timer, and the push-reminder plumbing (hidden without VAPID, validated with
* it).
*/
import { mkdirSync, readFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { spawn } from "node:child_process";
import path from "node:path";
@ -12,6 +12,7 @@ import {
BASE,
DECK,
REPO,
dueSrs,
launchBrowser,
makeChecker,
makePage,
@ -22,6 +23,7 @@ import {
spawnApp,
waitForHttp,
} from "./lib.mjs";
import { reminderPayload } from "../scripts/reminder-payload.mjs";
const { check, finish } = makeChecker();
@ -498,7 +500,11 @@ const browser = await launchBrowser();
{
check("push: port 3209 is free", !(await portInUse(3209)));
mkdirSync(path.join(REPO, "tests", ".tmp", "push"), { recursive: true });
const pushDir = path.join(REPO, "tests", ".tmp", "push");
mkdirSync(pushDir, { recursive: true });
/* Start from an empty store: a previous run's subscriptions (and their
reminder texts) must never be mistaken for this run's. */
rmSync(path.join(pushDir, "subscriptions.json"), { force: true });
const vapid = await new Promise((resolve) => {
const child = spawn("npx", ["web-push", "generate-vapid-keys", "--json"], { cwd: REPO });
let output = "";
@ -550,7 +556,11 @@ const browser = await launchBrowser();
}, subscription);
check("push: subscription stored", subscribe.status === 200 && subscribe.body.count === 1, JSON.stringify(subscribe.body));
const file = JSON.parse(readFileSync(path.join(REPO, "tests", ".tmp", "push", "subscriptions.json"), "utf8"));
check("push: stored without personal data", file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys");
check(
"push: a subscription with no reminder text stores nothing about the notes",
file.length === 1 && Object.keys(file[0]).sort().join(",") === "createdAt,endpoint,keys",
JSON.stringify(Object.keys(file[0] ?? {})),
);
const badBody = await page.evaluate(async () => {
const response = await fetch("/api/push", {
@ -562,6 +572,16 @@ const browser = await launchBrowser();
});
check("push: invalid subscription → 400", badBody === 400, String(badBody));
const badMessage = await page.evaluate(async (sub) => {
const response = await fetch("/api/push", {
method: "POST",
headers: { "Content-Type": "application/json", "x-ollama-key": "test-key" },
body: JSON.stringify({ ...sub, message: { title: "Hi", body: "There", url: "https://elsewhere.example/" } }),
});
return response.status;
}, subscription);
check("push: a reminder link that leaves the app → 400", badMessage === 400, String(badMessage));
const testSend = await page.evaluate(async (sub) => {
const response = await fetch("/api/push/test", {
method: "POST",
@ -581,6 +601,119 @@ const browser = await launchBrowser();
}, subscription);
check("push: delete path works", removed.status === 200 && removed.body.count === 0, JSON.stringify(removed.body));
await context.close();
/* 5b. the reminder text is composed in the browser, from the notes ---
The server never sees the notes, so the message each device wants
travels with its subscription. The app's own background refresh is
driven here with a faked browser subscription (the headless browser
has no push service of its own) — the composer, the store and the
read-back are all the real code. */
const subscriptionsFile = path.join(REPO, "tests", ".tmp", "push", "subscriptions.json");
const scenarios = [
{
name: "en, two cards due in Sky deck",
locale: "en",
decks: [DECK],
due: [dueSrs("seed_deck", "a1"), dueSrs("seed_deck", "a2")],
endpoint: "https://push.example.invalid/personal-en",
title: "Time to review",
body: "2 cards are due in Sky deck",
url: "/review",
},
{
name: "cs, one card due",
locale: "cs",
decks: [DECK],
due: [dueSrs("seed_deck", "a2")],
endpoint: "https://push.example.invalid/personal-cs",
title: "Čas na opakování",
body: "1 kartička čeká v sadě Sky deck",
url: "/review",
},
{
name: "en, no notes at all",
locale: "en",
decks: [],
due: [],
endpoint: "https://push.example.invalid/generic-en",
title: "Time to review",
body: "Your decks are waiting — a few due cards keep the streak alive.",
url: "/",
},
{
name: "cs, no notes at all",
locale: "cs",
decks: [],
due: [],
endpoint: "https://push.example.invalid/generic-cs",
title: "Čas na opakování",
body: "Sady na tebe čekají — pár kartiček udrží sérii.",
url: "/",
},
];
for (const scenario of scenarios) {
const personal = await browser.newContext({ viewport: { width: 390, height: 844 } });
const personalPage = await personal.newPage();
await seed(personalPage, { decks: scenario.decks, locale: scenario.locale });
await personalPage.goto("http://127.0.0.1:3209/", { waitUntil: "networkidle" });
await personalPage.waitForTimeout(700);
if (scenario.due.length > 0) {
/* The home screen reads the learning store on mount, so the mirror
is already hydrated: the records written here are picked up by a
reload, exactly like a browser that opened the app after studying. */
await seedLearning(personalPage, { srs: scenario.due });
await personalPage.reload({ waitUntil: "networkidle" });
await personalPage.waitForTimeout(700);
}
await personalPage.evaluate(async (endpoint) => {
const registration = await navigator.serviceWorker.ready;
Object.defineProperty(registration, "pushManager", {
configurable: true,
value: {
getSubscription: async () => ({
toJSON: () => ({ endpoint, keys: { p256dh: "B".repeat(64), auth: "A".repeat(22) } }),
}),
},
});
/* The component refreshes on visibility: the first pass (no real
subscription here) leaves it primed for exactly this event. */
document.dispatchEvent(new Event("visibilitychange"));
}, scenario.endpoint);
let stored = null;
for (let attempt = 0; attempt < 40 && !stored; attempt += 1) {
await personalPage.waitForTimeout(150);
const entries = JSON.parse(readFileSync(subscriptionsFile, "utf8"));
stored = entries.find((entry) => entry.endpoint === scenario.endpoint) ?? null;
}
check(
`push: ${scenario.name} — the browser-composed reminder`,
stored?.message?.title === scenario.title &&
stored?.message?.body === scenario.body &&
stored?.message?.url === scenario.url,
JSON.stringify(stored?.message ?? null),
);
await personal.close();
}
/* The sender script's fallback: a device with no text of its own gets
the operator's generic lines, and a link that leaves the app is never
sent. */
check("push: the sender prefers the device's own reminder text", (() => {
const message = { title: "Own title", body: "Own body", url: "/review" };
const payload = reminderPayload({ message }, {});
return payload.title === "Own title" && payload.body === "Own body" && payload.url === "/review";
})());
check("push: the sender falls back to the generic lines", (() => {
const payload = reminderPayload({}, { title: "Fallback", body: "Fallback body" });
return payload.title === "Fallback" && payload.body === "Fallback body" && payload.url === "/";
})());
check("push: the sender never sends a reminder link off the app", (() => {
const payload = reminderPayload({ message: { title: "T", body: "B", url: "https://elsewhere.example/" } }, {});
return payload.url === "/";
})());
}
} finally {
app.stop();