aboutsummaryrefslogtreecommitdiffstats
path: root/tools/notif-spam-score.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-03 18:19:14 -0700
committerAdam Soltys <[email protected]>2026-11-03 18:19:14 -0700
commit08821882bc323c3411940f6150b4a731c1833c49 (patch)
tree61244060215f248f24f7ef020d277cf9c4c2f3bc /tools/notif-spam-score.js
parent7e8f2b375aebd3809b6fe9c474c6a36b392163f2 (diff)
downloadcoinosv3-0882188.tar.gz
coinosv3-0882188.zip
The wallet's push notifications are worded so Chrome's spam model lets them through. "Any way to stop classifying v3 notifications as spam? I've seen this a few times now" (Adam, 2026-11-03, phone: "Possible spam — Chrome detected possible spam from v3.coinos.io", Unsubscribe / Show notification): Chrome on Android scores every web notification's title and body with an on-device model before showing it, hides it behind that warning above 90 of 100, and — nine warnings in a week on a phone whose engagement with the site is under 50 — revokes the site's notification permission outright (Chromium kAutoRevokeSuspiciousNotification, enabled by default). The model is a text classifier trained on Gemini-made examples of scam notifications, and our lines were their very shape: "Payment received" 95, "Incoming payment / Someone is trying to send you money…" 99, "Payment request / An app is asking your wallet to pay…" 99, "alice is calling / Voice call. Tap to answer." 99, "New message / You have a new private message." 92, "alice / sent you a message." 95, "New reply to your post / <text>" 98, "alice reacted ❤️ / to your post…" 97. Desktop Chrome runs the same model (without the warning) and reports each score to chrome://histograms, so the lines were measured rather than guessed — ~200 candidates on Chrome 156's model: "sats" reads as money without reading as a payment scam, "payment" doesn't; a sender's name in the body of a message or reaction line tips it over, "New chat" / "<name>" doesn't; any "reply" title is 96–99; a reply's own text is out of our hands (an @name or a link in it scores 97+), so it no longer rides along; "Calling" / "<name>" is 11–29 where every "<name> is calling" is 80–99. src/notify-text.js holds every line, with the measurements; the worker, the background responder and the renewal warning draw from it. The new lines score 8–46 (the shortcode reaction 67). tools/notif-spam-score.js re-measures them in a throwaway Chrome (SystemNotifications off, so nothing reaches the desktop) and fails at 90 — run it before changing any of them. On the phone that already warned, "Show notification" then "Always allow" marks the site trusted and stops both the warning and the revocation countHEADmaster
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
Diffstat (limited to 'tools/notif-spam-score.js')
-rw-r--r--tools/notif-spam-score.js82
1 files changed, 82 insertions, 0 deletions
diff --git a/tools/notif-spam-score.js b/tools/notif-spam-score.js
new file mode 100644
index 0000000..c9e48ba
--- /dev/null
+++ b/tools/notif-spam-score.js
@@ -0,0 +1,82 @@
+// Scores every notification the wallet can show against Chrome's on-device
+// "unwanted notification" model — the one that replaces a web notification
+// with "Possible spam" on Android when its title + body score above 90.
+//
+// Not part of the test suite: it launches a throwaway headful-less Chrome
+// (/usr/bin/google-chrome) that fetches the model from Google's optimization
+// guide on first use (~10 s, network), shows each line from src/notify-text.js
+// SAMPLES as a persistent notification on a local page, and reads the score
+// off chrome://histograms (SafeBrowsing.NotificationContentDetection
+// .SuspiciousScore, one new sample per notification). Desktop Chrome runs the
+// model too, flag or no flag; SystemNotifications is off so nothing reaches
+// the desktop's notification tray. Exit 1 if any line scores 90 or more.
+//
+// bun tools/notif-spam-score.js # SAMPLES from src/notify-text.js
+// bun tools/notif-spam-score.js "Title" "Body" ["Title" "Body" ...]
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import puppeteer from 'puppeteer-core';
+import { SAMPLES } from '../src/notify-text.js';
+
+const CHROME = process.env.CHROME || '/usr/bin/google-chrome';
+const PORT = 8766, DEBUG = 9445, THRESHOLD = 90;
+const argv = process.argv.slice(2);
+const items = argv.length ? argv.reduce((a, v, i) => (i % 2 ? a[a.length - 1].push(v) : a.push([v]), a), []) : SAMPLES;
+
+const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', fetch(req) {
+ const p = new URL(req.url).pathname;
+ if (p === '/sw.js') return new Response("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));", { headers: { 'content-type': 'text/javascript' } });
+ return new Response(`<!doctype html><title>score</title><script>navigator.serviceWorker.register('./sw.js');
+ window.show = async (t, b) => { const r = await navigator.serviceWorker.ready; await r.showNotification(t, { body: b, tag: 's' + Date.now() + Math.random() }); };</script>score`,
+ { headers: { 'content-type': 'text/html' } });
+} });
+const profile = mkdtempSync(join(tmpdir(), 'notif-score-'));
+const chrome = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${DEBUG}`, '--no-first-run', '--password-store=basic',
+ '--enable-features=ShowWarningsForSuspiciousNotifications', '--disable-features=SystemNotifications', 'about:blank'], { stdio: 'ignore' });
+const cleanup = () => { try { chrome.kill(); } catch {} try { server.stop(true); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} };
+process.on('exit', cleanup);
+let browser;
+for (let i = 0; i < 40 && !browser; i++) {
+ await Bun.sleep(500);
+ browser = await puppeteer.connect({ browserURL: `http://localhost:${DEBUG}`, defaultViewport: null }).catch(() => null);
+}
+if (!browser) { console.error('could not reach the scoring Chrome'); process.exit(2); }
+await browser.defaultBrowserContext().overridePermissions(`http://127.0.0.1:${PORT}`, ['notifications']);
+const page = await browser.newPage();
+await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'load' });
+await page.evaluate(() => navigator.serviceWorker.ready);
+const hist = await browser.newPage();
+async function counts() {
+ await hist.goto('chrome://histograms/SafeBrowsing.NotificationContentDetection.SuspiciousScore', { waitUntil: 'load' });
+ await Bun.sleep(250);
+ const txt = await hist.evaluate(() => document.body.innerText);
+ const m = {};
+ for (const line of txt.split('\n')) { const r = /^(\d+)\s+.*\((\d+) = /.exec(line); if (r) m[r[1]] = +r[2]; }
+ const t = /recorded (\d+) samples/.exec(txt);
+ return { m, total: t ? +t[1] : 0 };
+}
+// the model arrives on first use: warm up until one line scores
+let before = await counts();
+for (let i = 0; i < 12 && before.total === 0; i++) {
+ await page.evaluate(() => window.show('warm up', 'the model is still downloading'));
+ await Bun.sleep(5000);
+ before = await counts();
+}
+if (before.total === 0) { console.error('the model never scored anything (offline? no optimization-guide access?)'); process.exit(2); }
+let worst = 0;
+for (const [title, body] of items) {
+ await page.evaluate((t, b) => window.show(t, b), title, body);
+ let after, tries = 0;
+ do { await Bun.sleep(600); after = await counts(); } while (after.total === before.total && ++tries < 25);
+ const d = after.total - before.total;
+ const bucket = Object.keys(after.m).filter((k) => (after.m[k] || 0) > (before.m[k] || 0)).join('+');
+ const score = d === 1 ? Number(bucket) : NaN;
+ worst = Math.max(worst, score || 0);
+ console.log(`${(d === 1 ? String(score) : d === 0 ? '??' : 'ambiguous').padStart(9)} | ${title} | ${body}`);
+ before = after;
+}
+await page.close(); await hist.close(); browser.disconnect();
+console.log(worst >= THRESHOLD ? `\nFAIL: a line scored ${worst} (warning at >${THRESHOLD - 1})` : `\nok: every line under ${THRESHOLD}, worst ${worst}`);
+process.exit(worst >= THRESHOLD ? 1 : 0);