From bef9d694036389add33566a2fb5ce4e91b79431e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 10:27:24 +0000 Subject: [PATCH 1/2] Give TronBrowser its own Web Push service, and make it a setting ungoogled-chromium ships without a push service and a page cannot choose one, so pushManager.subscribe() failed on every site with 'Registration failed - push service error' (reproduced in the bundled 152 engine). - services/api: an RFC 8030 push service at /api/1/push. Site servers send standard aes128gcm + VAPID pushes (the sender's key must match the one the site subscribed with); ciphertext is queued until the browser acks it and delivered live over a WebSocket. 404/410/413/415/429 as senders expect. - extension: push-page.js (MAIN world) replaces PushManager.subscribe / getSubscription / permissionState; push-client.js holds the subscription keys, registers, decrypts (RFC 8291, WebCrypto) and shows the notification. - setting: Settings -> Push notifications. Default tronbrowser.dev, any compatible https URL (checked against GET / first), or off (engine's own). - /push docs page, privacy entry, migration 0007. Verified end to end in the real engine: subscribe returns a PushSubscription, a real sendPush gets 201, the notification is created with its click URL and the message is acked over the socket within 3s. Co-Authored-By: Claude Opus 5.5 (1M context) --- Caddyfile | 4 +- .../extensions/ai-sidebar/background.js | 4 + .../extensions/ai-sidebar/manifest.json | 27 +- .../extensions/ai-sidebar/options.html | 19 ++ apps/desktop/extensions/ai-sidebar/options.js | 37 +++ .../extensions/ai-sidebar/push-bridge.js | 26 ++ .../extensions/ai-sidebar/push-client.js | 227 +++++++++++++ .../extensions/ai-sidebar/push-crypto.js | 106 ++++++ .../extensions/ai-sidebar/push-page.js | 105 ++++++ apps/web/public/privacy.html | 1 + apps/web/public/push.html | 72 ++++ packages/storage/migrations/0007_push.sql | 42 +++ pnpm-lock.yaml | 29 ++ services/api/package.json | 2 + services/api/src/index.ts | 14 +- services/api/src/push/client.test.js | 98 ++++++ services/api/src/push/push.test.ts | 144 ++++++++ services/api/src/push/routes.ts | 311 ++++++++++++++++++ services/api/src/push/vapid.ts | 81 +++++ 19 files changed, 1346 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/extensions/ai-sidebar/push-bridge.js create mode 100644 apps/desktop/extensions/ai-sidebar/push-client.js create mode 100644 apps/desktop/extensions/ai-sidebar/push-crypto.js create mode 100644 apps/desktop/extensions/ai-sidebar/push-page.js create mode 100644 apps/web/public/push.html create mode 100644 packages/storage/migrations/0007_push.sql create mode 100644 services/api/src/push/client.test.js create mode 100644 services/api/src/push/push.test.ts create mode 100644 services/api/src/push/routes.ts create mode 100644 services/api/src/push/vapid.ts diff --git a/Caddyfile b/Caddyfile index bea51ee0..25b26266 100644 --- a/Caddyfile +++ b/Caddyfile @@ -52,7 +52,7 @@ # (no content hashing yet — don't let stale JS strand logged-in users). @images path *.svg *.png *.ico header @images Cache-Control "public, max-age=86400" - @code path *.js *.css *.html / /privacy /login /settings /dns + @code path *.js *.css *.html / /privacy /login /settings /dns /push header @code Cache-Control "public, max-age=60, must-revalidate" encode gzip zstd @@ -66,6 +66,8 @@ rewrite @settings /settings.html @dns path /dns rewrite @dns /dns.html + @push path /push + rewrite @push /push.html file_server diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index c4b01bee..8e82730b 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,5 +1,9 @@ import { decideInstallTarget, lookupInstalled } from './install-state.js'; import { PIT_SOCKS_PORT, pitProxyConfig } from './pit-proxy.js'; +import { installPush } from './push-client.js'; + +// Web Push through TronBrowser's configured push service (the engine has none). +installPush(); // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json index 1fc57671..7557721e 100644 --- a/apps/desktop/extensions/ai-sidebar/manifest.json +++ b/apps/desktop/extensions/ai-sidebar/manifest.json @@ -18,7 +18,9 @@ "scripting", "proxy", "privacy", - "notifications" + "notifications", + "alarms", + "contentSettings" ], "host_permissions": [ "https://api.openai.com/*", @@ -40,6 +42,29 @@ "type": "module" }, "content_scripts": [ + { + "matches": [ + "https://*/*", + "http://localhost/*", + "http://127.0.0.1/*" + ], + "js": [ + "push-page.js" + ], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [ + "https://*/*", + "http://localhost/*", + "http://127.0.0.1/*" + ], + "js": [ + "push-bridge.js" + ], + "run_at": "document_start" + }, { "matches": [ "https://chromewebstore.google.com/detail/*", diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html index 8d103083..18de06bc 100644 --- a/apps/desktop/extensions/ai-sidebar/options.html +++ b/apps/desktop/extensions/ai-sidebar/options.html @@ -89,6 +89,25 @@

Name resolution

name still needs the certificate that moshcode dns enable installs.

+

Push notifications

+

+ Sites send browser notifications through a push service the browser chooses. + The engine TronBrowser runs on ships without one, so TronBrowser brings its + own. Messages reach it encrypted to a key only this browser holds. +

+ + + + +

Changing it signs sites out of push; they subscribe again the next time they ask.

+

AI providers (bring your own keys)

Add keys for as many providers as you like, then pick a default for the sidebar. Keys are stored on your account (encrypted end-to-end when a vault diff --git a/apps/desktop/extensions/ai-sidebar/options.js b/apps/desktop/extensions/ai-sidebar/options.js index 3ddb85ca..398a83ff 100644 --- a/apps/desktop/extensions/ai-sidebar/options.js +++ b/apps/desktop/extensions/ai-sidebar/options.js @@ -9,6 +9,7 @@ import { } from "./coinpay-auth.js"; import { pushSettings, pullSettings } from "./settings-store.js"; import { encryptVault, decryptVault } from "./vault.js"; +import { DEFAULT_PUSH_SERVICE, pushServiceFrom } from "./push-client.js"; import { connect as btrConnect, disconnect as btrDisconnect, @@ -391,6 +392,42 @@ function flash(id, msg) { el(id).textContent = msg; setTimeout(() => (el(id).textContent = ""), 1600); } + +/* ---------- Push service (push-client.js reads `pushService`) ---------- */ +async function mountPush() { + const { pushService } = await chrome.storage.local.get("pushService"); + const mode = pushService === "off" ? "off" : pushService && pushService !== DEFAULT_PUSH_SERVICE ? "custom" : "default"; + el("pushMode").value = mode; + el("pushUrl").value = mode === "custom" ? pushService : ""; + el("pushCustomRow").hidden = mode !== "custom"; + el("pushMode").addEventListener("change", () => { + el("pushCustomRow").hidden = el("pushMode").value !== "custom"; + }); + el("savePush").addEventListener("click", async () => { + const choice = el("pushMode").value; + if (choice === "off") { + await chrome.storage.local.set({ pushService: "off" }); + return flash("savedPush", "push off ✓"); + } + if (choice === "default") { + await chrome.storage.local.remove("pushService"); + return flash("savedPush", "saved ✓"); + } + const url = pushServiceFrom(el("pushUrl").value); + if (!url || url === DEFAULT_PUSH_SERVICE) return flash("savedPush", "enter an https:// URL"); + // Check it speaks our protocol before switching every site over to it. + try { + const info = await (await fetch(url)).json(); + if (info.service !== "tronbrowser-push") throw new Error("not a compatible push service"); + } catch (e) { + return flash("savedPush", `can't use that URL: ${e.message}`); + } + await chrome.storage.local.set({ pushService: url }); + flash("savedPush", "saved ✓"); + }); +} +mountPush(); + function escape(s) { const d = document.createElement("div"); d.textContent = s || ""; diff --git a/apps/desktop/extensions/ai-sidebar/push-bridge.js b/apps/desktop/extensions/ai-sidebar/push-bridge.js new file mode 100644 index 00000000..0bd357fe --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/push-bridge.js @@ -0,0 +1,26 @@ +// Isolated-world half of the pushManager replacement: forwards push-page.js's +// requests to the background and hands the answers back. The background reads +// the origin from Chrome's sender info, so nothing the page sends here can act +// for another site. +(() => { + if (window.top !== window) return; + const TYPES = new Set(['push:subscribe', 'push:get', 'push:unsubscribe']); + window.addEventListener('message', (event) => { + const data = event.data; + if (event.source !== window || data?.__tronPush !== 'request' || !TYPES.has(data.type)) return; + const reply = (result) => + window.postMessage({ __tronPush: 'response', id: data.id, result }, location.origin); + try { + chrome.runtime.sendMessage( + { type: data.type, scope: typeof data.scope === 'string' ? data.scope : undefined, applicationServerKey: data.applicationServerKey ?? null }, + (result) => { + if (chrome.runtime.lastError) reply({ error: 'AbortError', message: 'Registration failed - push service error' }); + else reply(result); + }, + ); + } catch { + // The extension was reloaded under this page; behave as if push is off. + reply({ off: true }); + } + }); +})(); diff --git a/apps/desktop/extensions/ai-sidebar/push-client.js b/apps/desktop/extensions/ai-sidebar/push-client.js new file mode 100644 index 00000000..1fb57cfb --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/push-client.js @@ -0,0 +1,227 @@ +// Web Push for TronBrowser, background side. +// +// ungoogled-chromium ships without a push service, so every site's +// pushManager.subscribe() fails with "Registration failed - push service +// error". push-page.js replaces pushManager in pages; this module does the +// work: holds each subscription's keys, registers with the configured push +// service, keeps a socket to it, decrypts what arrives and shows it. +// +// The push service is a setting (`pushService` in chrome.storage.local): +// unset / DEFAULT_PUSH_SERVICE TronBrowser's own, https://tronbrowser.dev/api/1/push +// any https URL a compatible service (self-hosted, say) +// 'off' leave pushManager alone (the engine's own behaviour) +import { createSubscriptionKeys, decryptPush, fromB64u, notificationFromPayload, toB64u } from './push-crypto.js'; + +export const DEFAULT_PUSH_SERVICE = 'https://tronbrowser.dev/api/1/push'; +const SUBS_KEY = 'pushSubscriptions'; // { [origin|scope]: { token, endpoint, p256dh, auth, privateJwk, appKey, service } } +const PING_MS = 20_000; // keeps the socket, and so this worker, alive (Chrome 116+) + +/** The configured push service URL, or null when push is switched off. */ +export function pushServiceFrom(value) { + if (value === 'off') return null; + if (typeof value === 'string' && /^https:\/\/[^\s]+$/.test(value.trim())) return value.trim().replace(/\/$/, ''); + if (typeof value === 'string' && /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(value.trim())) return value.trim().replace(/\/$/, ''); + return DEFAULT_PUSH_SERVICE; +} + +async function service() { + const { pushService } = await chrome.storage.local.get('pushService'); + return pushServiceFrom(pushService); +} + +async function deviceSecret() { + const { pushDeviceSecret } = await chrome.storage.local.get('pushDeviceSecret'); + if (pushDeviceSecret) return pushDeviceSecret; + const secret = toB64u(crypto.getRandomValues(new Uint8Array(32))); + await chrome.storage.local.set({ pushDeviceSecret: secret }); + return secret; +} + +async function subscriptions() { + return (await chrome.storage.local.get(SUBS_KEY))[SUBS_KEY] || {}; +} + +const subKey = (origin, scope) => `${origin}|${scope || origin + '/'}`; + +/** What the page sees: the same shape PushSubscription.toJSON() gives. */ +function publicShape(sub) { + return { endpoint: sub.endpoint, expirationTime: null, keys: { p256dh: sub.p256dh, auth: sub.auth }, applicationServerKey: sub.appKey }; +} + +async function api(base, path, init = {}) { + const res = await fetch(`${base}${path}`, { + ...init, + headers: { authorization: `Bearer ${await deviceSecret()}`, 'content-type': 'application/json', ...(init.headers || {}) }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || `push service answered ${res.status}`); + return body; +} + +export async function subscribe(origin, scope, appKey) { + const base = await service(); + if (!base) return { off: true }; + const all = await subscriptions(); + const key = subKey(origin, scope); + const existing = all[key]; + if (existing && existing.service === base) { + if ((existing.appKey || null) === (appKey || null)) return { subscription: publicShape(existing) }; + // Native behaviour: a different key on an existing subscription is an error. + return { error: 'InvalidStateError', message: 'A subscription with a different applicationServerKey already exists.' }; + } + const keys = await createSubscriptionKeys(); + const created = await api(base, '/subscriptions', { + method: 'POST', + body: JSON.stringify({ origin, applicationServerKey: appKey || null }), + }); + all[key] = { ...keys, token: created.token, endpoint: created.endpoint, appKey: appKey || null, service: base, origin }; + await chrome.storage.local.set({ [SUBS_KEY]: all }); + connect(); + return { subscription: publicShape(all[key]) }; +} + +export async function getSubscription(origin, scope) { + const base = await service(); + if (!base) return { off: true }; + const sub = (await subscriptions())[subKey(origin, scope)]; + return { subscription: sub && sub.service === base ? publicShape(sub) : null }; +} + +export async function unsubscribe(origin, scope) { + const all = await subscriptions(); + const key = subKey(origin, scope); + const sub = all[key]; + if (!sub) return { ok: false }; + delete all[key]; + await chrome.storage.local.set({ [SUBS_KEY]: all }); + await api(sub.service, `/subscriptions/${sub.token}`, { method: 'DELETE' }).catch(() => undefined); + return { ok: true }; +} + +/* ---------- receiving ---------- */ + +async function show(message) { + const sub = Object.values(await subscriptions()).find((s) => s.token === message.token); + if (!sub) return; // unsubscribed since; the ack still clears it + // Respect the site's notification permission if the user has since blocked it. + if (chrome.contentSettings?.notifications) { + const { setting } = await chrome.contentSettings.notifications.get({ primaryUrl: `${sub.origin}/` }).catch(() => ({})); + if (setting === 'block') return; + } + let text = ''; + if (message.body) text = new TextDecoder().decode(await decryptPush(fromB64u(message.body), sub)); + const n = notificationFromPayload(text, sub.origin); + const id = `push:${message.id}`; + const options = { + type: 'basic', + iconUrl: n.icon || chrome.runtime.getURL('icons/icon-128.png'), + title: n.title, + message: n.body || n.host, + contextMessage: n.host, + }; + try { + await chrome.notifications.create(id, options); + } catch { + await chrome.notifications.create(id, { ...options, iconUrl: chrome.runtime.getURL('icons/icon-128.png') }); + } + await chrome.storage.session.set({ [id]: n.url }); +} + +async function handle(messages, ackVia) { + const ids = []; + for (const message of messages || []) { + try { await show(message); } catch (error) { console.warn('[push] could not show a message', error); } + ids.push(message.id); // an unreadable push will not become readable later + } + if (ids.length) await ackVia(ids); +} + +let socket = null; +let pinger = null; + +export async function connect() { + const base = await service(); + if (!base || socket) return; + if (!Object.values(await subscriptions()).some((s) => s.service === base)) return; + const secret = await deviceSecret(); + let ws; + try { ws = new WebSocket(`${base.replace(/^http/, 'ws')}/connect`); } catch { return; } + socket = ws; + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'hello', secret })); + pinger = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping' })); } catch { /* closing */ } }, PING_MS); + }; + ws.onmessage = (event) => { + let frame; + try { frame = JSON.parse(event.data); } catch { return; } + if (frame.type === 'messages') { + handle(frame.messages, async (ids) => ws.send(JSON.stringify({ type: 'ack', ids }))); + } + }; + ws.onclose = () => { + clearInterval(pinger); + if (socket === ws) socket = null; + }; +} + +/** The fallback when the socket was down: fetch and show whatever is waiting. */ +export async function poll() { + const base = await service(); + if (!base) return; + if (!Object.values(await subscriptions()).some((s) => s.service === base)) return; + const { messages } = await api(base, '/messages'); + await handle(messages, (ids) => api(base, '/ack', { method: 'POST', body: JSON.stringify({ ids }) })); +} + +export function installPush() { + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (typeof msg?.type !== 'string' || !msg.type.startsWith('push:')) return false; + // The origin comes from Chrome, never from the page: a site can only + // manage its own subscriptions. + const origin = sender.origin || (sender.url ? new URL(sender.url).origin : null); + if (!origin || !/^https:|^http:\/\/(localhost|127\.0\.0\.1)/.test(origin)) { + sendResponse({ error: 'NotSupportedError', message: 'Push needs a secure origin.' }); + return false; + } + const run = { + 'push:subscribe': () => subscribe(origin, msg.scope, msg.applicationServerKey), + 'push:get': () => getSubscription(origin, msg.scope), + 'push:unsubscribe': () => unsubscribe(origin, msg.scope), + }[msg.type]; + if (!run) return false; + run().then(sendResponse, (error) => sendResponse({ error: 'AbortError', message: `Registration failed - ${error.message}` })); + return true; + }); + + chrome.notifications?.onClicked?.addListener(async (id) => { + if (!id.startsWith('push:')) return; + const url = (await chrome.storage.session.get(id))[id]; + if (url) chrome.tabs.create({ url }); + chrome.notifications.clear(id); + chrome.storage.session.remove(id); + }); + chrome.notifications?.onClosed?.addListener((id) => { + if (id.startsWith('push:')) chrome.storage.session.remove(id); + }); + + // A changed service strands the old subscriptions; forget them so sites + // see no subscription and subscribe again against the new one. + chrome.storage?.onChanged?.addListener(async (changes, area) => { + if (area !== 'local' || !changes.pushService) return; + socket?.close(); + const base = await service(); + const all = await subscriptions(); + for (const [key, sub] of Object.entries(all)) if (sub.service !== base) delete all[key]; + await chrome.storage.local.set({ [SUBS_KEY]: all }); + connect(); + }); + + chrome.alarms?.create('push-poll', { periodInMinutes: 1 }); + chrome.alarms?.onAlarm?.addListener((alarm) => { + if (alarm.name !== 'push-poll') return; + if (!socket) connect(); + poll().catch(() => undefined); + }); + chrome.runtime.onStartup?.addListener(() => { connect(); poll().catch(() => undefined); }); + connect().catch(() => undefined); +} diff --git a/apps/desktop/extensions/ai-sidebar/push-crypto.js b/apps/desktop/extensions/ai-sidebar/push-crypto.js new file mode 100644 index 00000000..7e827c7b --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/push-crypto.js @@ -0,0 +1,106 @@ +// The browser end of Web Push encryption (RFC 8291 over RFC 8188 aes128gcm), +// in WebCrypto only, so it runs in the extension's service worker and in node +// tests alike. Browsers normally do this inside their push stack; TronBrowser's +// engine has none, so the extension holds the subscription keys itself. + +const subtle = () => globalThis.crypto.subtle; +const enc = new TextEncoder(); + +export function toB64u(bytes) { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function fromB64u(text) { + const normal = String(text).replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(normal + '==='.slice((normal.length + 3) % 4)); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +const concat = (...parts) => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { out.set(p, at); at += p.length; } + return out; +}; + +async function hkdf(salt, ikm, info, length) { + const key = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveBits']); + const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8); + return new Uint8Array(bits); +} + +/** + * A fresh subscription keypair: `p256dh` (public, raw 65 bytes) and `auth` + * (16 random bytes) go to the site; `privateJwk` never leaves the extension. + */ +export async function createSubscriptionKeys() { + const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const raw = new Uint8Array(await subtle().exportKey('raw', pair.publicKey)); + const privateJwk = await subtle().exportKey('jwk', pair.privateKey); + const auth = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return { p256dh: toB64u(raw), auth: toB64u(auth), privateJwk }; +} + +/** + * Decrypt one aes128gcm Web Push body with the subscription's keys. + * Returns the plaintext bytes. Throws on anything malformed or forged. + */ +export async function decryptPush(body, keys) { + const data = body instanceof Uint8Array ? body : new Uint8Array(body); + if (data.length < 21) throw new Error('push body too short'); + const salt = data.subarray(0, 16); + const rs = new DataView(data.buffer, data.byteOffset + 16, 4).getUint32(0); + const idlen = data[20]; + const senderPublic = data.subarray(21, 21 + idlen); + const ciphertext = data.subarray(21 + idlen); + if (idlen !== 65 || senderPublic[0] !== 4) throw new Error('push body has no sender key'); + if (ciphertext.length > rs) throw new Error('multi-record push bodies are not used by Web Push'); + + const receiverPublic = fromB64u(keys.p256dh); + const privateKey = await subtle().importKey('jwk', keys.privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + const senderKey = await subtle().importKey('raw', senderPublic, { name: 'ECDH', namedCurve: 'P-256' }, false, []); + const shared = new Uint8Array(await subtle().deriveBits({ name: 'ECDH', public: senderKey }, privateKey, 256)); + + // RFC 8291 §3.4: mix the auth secret and both public keys into the IKM. + const ikm = await hkdf(fromB64u(keys.auth), shared, + concat(enc.encode('WebPush: info\0'), receiverPublic, senderPublic), 32); + const cek = await hkdf(salt, ikm, enc.encode('Content-Encoding: aes128gcm\0'), 16); + const nonce = await hkdf(salt, ikm, enc.encode('Content-Encoding: nonce\0'), 12); + + const aes = await subtle().importKey('raw', cek, 'AES-GCM', false, ['decrypt']); + const padded = new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce }, aes, ciphertext)); + // Single (last) record: content, then 0x02, then zero padding. + let end = padded.length - 1; + while (end >= 0 && padded[end] === 0) end--; + if (end < 0 || padded[end] !== 2) throw new Error('bad push padding'); + return padded.subarray(0, end); +} + +/** + * What to show for a decrypted payload. Understands the common shapes: our + * house `{ title, body, url, icon }`, FCM-style `{ notification: {...} }`, and + * plain text. Anything else becomes the body. + */ +export function notificationFromPayload(text, origin) { + let data; + try { data = JSON.parse(text); } catch { data = { body: text }; } + if (typeof data !== 'object' || data === null) data = { body: String(data) }; + const n = typeof data.notification === 'object' && data.notification ? { ...data, ...data.notification } : data; + const host = (() => { try { return new URL(origin).host; } catch { return origin; } })(); + const title = String(n.title || host).slice(0, 200); + const body = String(n.body ?? n.message ?? '').slice(0, 1000); + const rawUrl = n.url ?? n.click_action ?? n.data?.url ?? '/'; + let url = origin; + try { + const resolved = new URL(rawUrl, origin); + if (resolved.protocol === 'https:' || resolved.protocol === 'http:') url = resolved.href; + } catch { /* keep the origin */ } + let icon = null; + try { if (n.icon) icon = new URL(n.icon, origin).href; } catch { /* no icon */ } + return { title, body, url, icon, host }; +} diff --git a/apps/desktop/extensions/ai-sidebar/push-page.js b/apps/desktop/extensions/ai-sidebar/push-page.js new file mode 100644 index 00000000..a32946c7 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/push-page.js @@ -0,0 +1,105 @@ +// Runs in every page's own world (MAIN), before its scripts. Replaces +// PushManager's methods so subscribe() goes to TronBrowser's configured push +// service instead of the engine's, which does not exist in ungoogled-chromium. +// Talks to push-bridge.js (isolated world) by window messages; the background +// does the rest (push-client.js). With the setting 'off', every call falls +// through to the engine's own implementation. +(() => { + if (typeof PushManager === 'undefined' || typeof ServiceWorkerRegistration === 'undefined') return; + if (window.top !== window) return; // top-level documents only, like the bridge + const native = { + subscribe: PushManager.prototype.subscribe, + getSubscription: PushManager.prototype.getSubscription, + permissionState: PushManager.prototype.permissionState, + }; + const scopes = new WeakMap(); // PushManager -> its registration's scope + const pmGetter = Object.getOwnPropertyDescriptor(ServiceWorkerRegistration.prototype, 'pushManager')?.get; + if (pmGetter) { + Object.defineProperty(ServiceWorkerRegistration.prototype, 'pushManager', { + configurable: true, + enumerable: true, + get() { + const pm = pmGetter.call(this); + if (pm && !scopes.has(pm)) scopes.set(pm, this.scope); + return pm; + }, + }); + } + + let seq = 0; + const waiting = new Map(); + window.addEventListener('message', (event) => { + if (event.source !== window || event.data?.__tronPush !== 'response') return; + const done = waiting.get(event.data.id); + if (done) { waiting.delete(event.data.id); done(event.data.result || {}); } + }); + const ask = (type, payload) => + new Promise((resolve) => { + const id = `${Date.now()}-${++seq}`; + waiting.set(id, resolve); + window.postMessage({ __tronPush: 'request', id, type, ...payload }, location.origin); + }); + + const toB64u = (input) => { + if (typeof input === 'string') return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const u8 = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }; + const fromB64u = (text) => { + const normal = text.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(normal + '==='.slice((normal.length + 3) % 4)); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out.buffer; + }; + const fail = (result) => { throw new DOMException(result.message || 'Registration failed', result.error || 'AbortError'); }; + + // A real PushSubscription as far as the page can tell: instanceof holds and + // the fields, getKey, toJSON and unsubscribe behave as specified. + function subscriptionFrom(data, scope) { + const sub = Object.create(PushSubscription.prototype); + const appKey = data.applicationServerKey ? fromB64u(data.applicationServerKey) : null; + const define = (name, value) => Object.defineProperty(sub, name, { value, enumerable: true }); + define('endpoint', data.endpoint); + define('expirationTime', null); + define('options', Object.freeze({ userVisibleOnly: true, applicationServerKey: appKey })); + define('getKey', (name) => (data.keys[name] ? fromB64u(data.keys[name]) : null)); + define('toJSON', () => ({ endpoint: data.endpoint, expirationTime: null, keys: { ...data.keys } })); + define('unsubscribe', async () => (await ask('push:unsubscribe', { scope })).ok === true); + return sub; + } + + PushManager.prototype.subscribe = async function (options = {}) { + const scope = scopes.get(this); + if (options.userVisibleOnly === false) { + throw new DOMException('Push subscriptions must be userVisibleOnly.', 'NotAllowedError'); + } + const probe = await ask('push:get', { scope }); + if (probe.off) return native.subscribe.call(this, options); + if (Notification.permission === 'default') await Notification.requestPermission(); + if (Notification.permission !== 'granted') { + throw new DOMException('Registration failed - permission denied', 'NotAllowedError'); + } + const appKey = options.applicationServerKey == null ? null : toB64u(options.applicationServerKey); + const result = await ask('push:subscribe', { scope, applicationServerKey: appKey }); + if (result.off) return native.subscribe.call(this, options); + if (result.error) fail(result); + return subscriptionFrom(result.subscription, scope); + }; + + PushManager.prototype.getSubscription = async function () { + const scope = scopes.get(this); + const result = await ask('push:get', { scope }); + if (result.off) return native.getSubscription.call(this); + if (result.error) fail(result); + return result.subscription ? subscriptionFrom(result.subscription, scope) : null; + }; + + PushManager.prototype.permissionState = async function (options) { + const result = await ask('push:get', { scope: scopes.get(this) }); + if (result.off) return native.permissionState.call(this, options); + return Notification.permission === 'default' ? 'prompt' : Notification.permission; + }; +})(); diff --git a/apps/web/public/privacy.html b/apps/web/public/privacy.html index c3821895..b02190fb 100644 --- a/apps/web/public/privacy.html +++ b/apps/web/public/privacy.html @@ -37,6 +37,7 @@

The browser

  • No telemetry by default. TronBrowser does not phone home. It is built on Ungoogled Chromium, which removes Google background connections.
  • Your data is yours. Bookmarks, history, and profiles stay on your device, or in a database you control (your own SQLite, or our optional managed cloud).
  • Bring your own AI keys. When you use the AI sidebar, requests go directly from your browser to the AI provider you configured; your keys are stored locally on your device.
  • +
  • Push notifications. The engine has no push service, so TronBrowser uses its own (tronbrowser.dev/api/1/push by default, or one you choose, or off). It connects only after a site you allowed notifications for subscribes. Messages are encrypted to a key only your browser holds; the service keeps the subscribing site's origin and each message until it is delivered or expires. How it works.
  • No ads, no sponsored tabs, no affiliate link injection.
  • diff --git a/apps/web/public/push.html b/apps/web/public/push.html new file mode 100644 index 00000000..226f1a2e --- /dev/null +++ b/apps/web/public/push.html @@ -0,0 +1,72 @@ + + + + + +Push service · TronBrowser + + + + + + + + +

    Push service

    + +

    Browser notifications travel through a push service that the browser picks: + Chrome uses Google's, Firefox uses Mozilla's, Safari uses Apple's. The ungoogled-chromium + engine TronBrowser runs on has none, so on a stock build every site's + pushManager.subscribe() fails with + Registration failed - push service error.

    + +

    TronBrowser brings its own. The built-in extension supplies pushManager to + every page and registers with https://tronbrowser.dev/api/1/push by default. + Change it under Settings → Push notifications: another compatible + service, or off.

    + +

    For site owners: nothing to change

    +

    Your site subscribes the usual way and gets an endpoint on our service. Your server sends + exactly what it sends to FCM or Mozilla: an aes128gcm body (RFC 8291) with a + VAPID header (RFC 8292), via web-push, @profullstack/notifications + or anything else that speaks the standard. We answer 201, and 404 / + 410 when a subscription is gone, so your cleanup keeps working.

    +

    One difference: the notification is shown by TronBrowser from the payload's + title, body, icon and url (also + notification.* and click_action). Your service worker's + push handler does not run, so a silent push that only syncs data shows as a + plain notification.

    + +

    Privacy

    + + +

    The protocol, for a self-hosted service

    + + + + + + + + +
    RequestWhoWhat
    GET /settings page{"service":"tronbrowser-push","version":1}, which is how the browser checks a custom URL
    POST /{token}site serversRFC 8030 push. TTL required, body ≤ 4096 bytes, Topic replaces a pending message
    POST /subscriptionsbrowser{origin, applicationServerKey} → {token, endpoint}
    DELETE /subscriptions/{token}browserunsubscribe; later pushes get 410
    GET /messages, POST /ackbrowserpending messages, then {ids} to acknowledge
    GET /connectbrowserWebSocket. Send {"type":"hello","secret"}; receive {"type":"messages"}; reply {"type":"ack","ids"}
    +

    Browser calls carry Authorization: Bearer <device secret>. + The source is in services/api/src/push.

    + + diff --git a/packages/storage/migrations/0007_push.sql b/packages/storage/migrations/0007_push.sql new file mode 100644 index 00000000..50517284 --- /dev/null +++ b/packages/storage/migrations/0007_push.sql @@ -0,0 +1,42 @@ +-- TronBrowser's own Web Push service (tronbrowser.dev/api/1/push). +-- +-- ungoogled-chromium has no push service, so pushManager.subscribe() fails for +-- every site. The bundled extension supplies pushManager instead and registers +-- here; site servers POST standard RFC 8030/8291/8292 pushes to the endpoint we +-- hand out, and we relay the still-encrypted payload to the browser. +-- +-- A device is an install of the extension, identified by the sha256 of a +-- secret only it holds: no account, no email. Payloads are stored as the +-- ciphertext the site sent; only the browser holds the key to read them. + +CREATE TABLE IF NOT EXISTS push_devices ( + id TEXT PRIMARY KEY, -- sha256 hex of the device secret + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + token TEXT PRIMARY KEY, -- the secret last segment of the endpoint + device_id TEXT NOT NULL, + origin TEXT NOT NULL, -- the site that subscribed + app_key TEXT, -- its VAPID public key (base64url), when given + created_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, -- kept so senders get 410, not 404 + FOREIGN KEY (device_id) REFERENCES push_devices(id) +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_device ON push_subscriptions (device_id); + +CREATE TABLE IF NOT EXISTS push_messages ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL, + device_id TEXT NOT NULL, + body TEXT NOT NULL, -- base64url ciphertext ('' for a push with no data) + encoding TEXT, -- Content-Encoding, always aes128gcm when body is set + urgency TEXT NOT NULL DEFAULT 'normal', + topic TEXT, -- a newer message with the same topic replaces it + expires_at INTEGER NOT NULL, -- unix seconds (now + TTL) + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_push_messages_device ON push_messages (device_id, expires_at); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66cbb6fb..1a1cd952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: '@hono/node-server': specifier: ^1.13.7 version: 1.19.14(hono@4.12.27) + '@hono/node-ws': + specifier: ^1.3.1 + version: 1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27) '@langchain/anthropic': specifier: ^1.5.1 version: 1.5.1(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) @@ -307,6 +310,9 @@ importers: specifier: ^4.6.14 version: 4.12.27 devDependencies: + '@profullstack/notifications': + specifier: ^0.1.3 + version: 0.1.3 typescript: specifier: ^5.6.3 version: 5.9.3 @@ -1042,6 +1048,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.3.1': + resolution: {integrity: sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.19.11 + hono: ^4.6.0 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1227,6 +1240,10 @@ packages: '@profullstack/emailer@1.0.1': resolution: {integrity: sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==} + '@profullstack/notifications@0.1.3': + resolution: {integrity: sha512-BtP67qx/+AQuKNtgIoxZWS2cTr1zc2hKTP7mqPhVpiEoxAgCEYHqtr+5aMRRAu+mmElXQSKjaU7ydbGKzG+WPg==} + engines: {node: '>=20.11'} + '@profullstack/referrals@0.1.0': resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} engines: {node: '>=18'} @@ -2494,6 +2511,7 @@ packages: libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lighthouse-logger@1.4.2: @@ -4451,6 +4469,15 @@ snapshots: dependencies: hono: 4.12.27 + '@hono/node-ws@1.3.1(@hono/node-server@1.19.14(hono@4.12.27))(hono@4.12.27)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + hono: 4.12.27 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4647,6 +4674,8 @@ snapshots: '@profullstack/emailer@1.0.1': {} + '@profullstack/notifications@0.1.3': {} + '@profullstack/referrals@0.1.0(react@19.2.7)': optionalDependencies: react: 19.2.7 diff --git a/services/api/package.json b/services/api/package.json index ba0dd1a2..6e081381 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@hono/node-server": "^1.13.7", + "@hono/node-ws": "^1.3.1", "@langchain/anthropic": "^1.5.1", "@langchain/langgraph": "^1.4.6", "@langchain/openai": "^1.5.3", @@ -26,6 +27,7 @@ "hono": "^4.6.14" }, "devDependencies": { + "@profullstack/notifications": "^0.1.3", "typescript": "^5.6.3", "vitest": "^2.1.4" } diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 89ab8f81..946e9fe8 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -1,4 +1,5 @@ import { serve } from '@hono/node-server'; +import { createNodeWebSocket } from '@hono/node-ws'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; @@ -15,6 +16,8 @@ import { dnsRoutes } from './dns.js'; import { tronRelay } from './mcp/tron.js'; import { safeRedirect } from './redirect.js'; import { extLoginTarget } from './ext-login.js'; +import { db } from './db.js'; +import { pushService } from './push/routes.js'; const CP = { clientId: process.env.COINPAY_CLIENT_ID || '', @@ -28,6 +31,7 @@ const CP = { const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev'; const app = new Hono(); +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); app.use('*', cors({ origin: (o) => o || '*', credentials: true })); const cookieOpts = { httpOnly: true, secure: true, sameSite: 'Lax' as const, path: '/', maxAge: SESSION_TTL }; @@ -51,6 +55,13 @@ async function startSession(c: any, userId: string, redirect?: string) { app.get('/api/healthz', (c) => c.json({ ok: true })); +/* ---------- Web Push service: tronbrowser.dev/api/1/push ---------- */ +// ungoogled-chromium has no push service; the bundled extension registers +// here instead (the push service is a TronBrowser setting, this is the default). +const push = pushService({ db, publicBase: `${APP_URL}/api/1/push`, upgradeWebSocket }); +app.route('/api/1/push', push.app); +setInterval(() => push.sweep().catch(() => undefined), 3600_000).unref(); + /* ---------- Extension store (tronbrowser.dev/store) ---------- */ app.route('/api/store', store); @@ -243,4 +254,5 @@ function baseUrl(c: any): string { } const port = Number(process.env.PORT || 8080); -serve({ fetch: app.fetch, port }, () => console.log(`tronbrowser api on :${port}`)); +const server = serve({ fetch: app.fetch, port }, () => console.log(`tronbrowser api on :${port}`)); +injectWebSocket(server); diff --git a/services/api/src/push/client.test.js b/services/api/src/push/client.test.js new file mode 100644 index 00000000..a2c7102e --- /dev/null +++ b/services/api/src/push/client.test.js @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { createClient } from '@libsql/client'; +import { readFileSync } from 'node:fs'; +import { generateVapidKeys, sendPush } from '@profullstack/notifications/server'; +import { pushService } from './routes.ts'; +import { DEFAULT_PUSH_SERVICE, poll, pushServiceFrom, subscribe, unsubscribe, getSubscription } from '../../../../apps/desktop/extensions/ai-sidebar/push-client.js'; + +const migration = readFileSync(new URL('../../../../packages/storage/migrations/0007_push.sql', import.meta.url), 'utf8'); + +function fakeChrome() { + const local = {}; + const session = {}; + const area = (store) => ({ + async get(keys) { + const list = typeof keys === 'string' ? [keys] : keys; + return Object.fromEntries(list.filter((k) => k in store).map((k) => [k, structuredClone(store[k])])); + }, + async set(obj) { Object.assign(store, structuredClone(obj)); }, + async remove(k) { delete store[k]; }, + }); + return { + local, + session, + notifications: { create: vi.fn(async () => 'id') }, + storage: { local: area(local), session: area(session) }, + runtime: { getURL: (p) => `chrome-extension://tron/${p}` }, + }; +} + +let app; +beforeEach(async () => { + const client = createClient({ url: ':memory:' }); + await client.executeMultiple(migration); + const service = pushService({ db: () => client, publicBase: DEFAULT_PUSH_SERVICE }); + app = new Hono(); + app.route('/api/1/push', service.app); + globalThis.chrome = fakeChrome(); + vi.stubGlobal('fetch', (url, init) => app.request(String(url), init)); + vi.stubGlobal('WebSocket', class { constructor() { throw new Error('no sockets in tests'); } }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + delete globalThis.chrome; +}); + +describe('pushServiceFrom', () => { + it('defaults to ours, honours off and an https URL, ignores junk', () => { + expect(pushServiceFrom(undefined)).toBe('https://tronbrowser.dev/api/1/push'); + expect(pushServiceFrom('off')).toBeNull(); + expect(pushServiceFrom('https://push.example.com/api/1/push/')).toBe('https://push.example.com/api/1/push'); + expect(pushServiceFrom('http://evil.example/push')).toBe(DEFAULT_PUSH_SERVICE); + }); +}); + +describe('push client', () => { + it('a site subscribes, its server pushes, TronBrowser shows it', async () => { + const vapid = generateVapidKeys(); + const { subscription } = await subscribe('https://agenticjobs.work', undefined, vapid.publicKey); + expect(subscription.endpoint.startsWith(`${DEFAULT_PUSH_SERVICE}/`)).toBe(true); + // Asking again returns the same subscription, as the native API does. + expect((await getSubscription('https://agenticjobs.work')).subscription.endpoint).toBe(subscription.endpoint); + + const sent = await sendPush(subscription, JSON.stringify({ title: 'New match', body: 'rust / remote', url: '/jobs/x' }), { + keys: vapid, subject: 'mailto:ops@agenticjobs.work', fetch: (u, i) => app.request(String(u), i), + }); + expect(sent.sent).toBe(true); + + await poll(); + expect(chrome.notifications.create).toHaveBeenCalledTimes(1); + const [id, shown] = chrome.notifications.create.mock.calls[0]; + expect(shown).toMatchObject({ title: 'New match', message: 'rust / remote', contextMessage: 'agenticjobs.work' }); + expect(chrome.session[id]).toBe('https://agenticjobs.work/jobs/x'); + + // Acked: a second poll shows nothing new. + await poll(); + expect(chrome.notifications.create).toHaveBeenCalledTimes(1); + }); + + it('a different applicationServerKey on an existing subscription is refused, like the native API', async () => { + await subscribe('https://a.example', undefined, generateVapidKeys().publicKey); + const again = await subscribe('https://a.example', undefined, generateVapidKeys().publicKey); + expect(again.error).toBe('InvalidStateError'); + }); + + it('unsubscribing tells the service, so the sender learns it is gone', async () => { + const vapid = generateVapidKeys(); + const { subscription } = await subscribe('https://a.example', undefined, vapid.publicKey); + await unsubscribe('https://a.example'); + const sent = await sendPush(subscription, 'hi', { keys: vapid, subject: 'mailto:x@example.com', fetch: (u, i) => app.request(String(u), i) }); + expect(sent.gone).toBe(true); + }); + + it('with push off, the page falls through to the engine', async () => { + chrome.local.pushService = 'off'; + expect(await subscribe('https://a.example', undefined, null)).toEqual({ off: true }); + }); +}); diff --git a/services/api/src/push/push.test.ts b/services/api/src/push/push.test.ts new file mode 100644 index 00000000..34707b19 --- /dev/null +++ b/services/api/src/push/push.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { createClient, type Client } from '@libsql/client'; +import { readFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { generateVapidKeys, sendPush, vapidHeader } from '@profullstack/notifications/server'; +import { pushService } from './routes.js'; +import { verifyVapid } from './vapid.js'; +// The extension's own decryptor: the test proves a real sender's push reaches +// the browser end readable, not just that the server returned 201. +import { + createSubscriptionKeys, + decryptPush, + fromB64u, + notificationFromPayload, +} from '../../../../apps/desktop/extensions/ai-sidebar/push-crypto.js'; + +const BASE = 'https://tronbrowser.dev/api/1/push'; +const migration = readFileSync(new URL('../../../../packages/storage/migrations/0007_push.sql', import.meta.url), 'utf8'); + +let client: Client; +let app: Hono; +let service: ReturnType; +const secret = randomBytes(32).toString('base64url'); +const auth = { authorization: `Bearer ${secret}`, 'content-type': 'application/json' }; + +beforeEach(async () => { + client = createClient({ url: ':memory:' }); + await client.executeMultiple(migration); + service = pushService({ db: () => client, publicBase: BASE }); + app = new Hono(); + app.route('/api/1/push', service.app); +}); + +const fetchVia = (url: string | URL | Request, init?: RequestInit) => app.request(String(url), init); + +async function subscribe(applicationServerKey: string | null) { + const res = await app.request(`${BASE}/subscriptions`, { + method: 'POST', + headers: auth, + body: JSON.stringify({ origin: 'https://agenticjobs.work', applicationServerKey }), + }); + expect(res.status).toBe(201); + return (await res.json()) as { token: string; endpoint: string }; +} + +describe('tronbrowser push service', () => { + it('relays a real Web Push end to end, and only the browser can read it', async () => { + const vapid = generateVapidKeys(); + const keys = await createSubscriptionKeys(); + const { endpoint } = await subscribe(vapid.publicKey); + expect(endpoint.startsWith(`${BASE}/`)).toBe(true); + + const payload = JSON.stringify({ title: 'New match', body: 'rust / remote', url: '/jobs/x' }); + const result = await sendPush( + { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }, + payload, + { keys: vapid, subject: 'mailto:ops@agenticjobs.work', fetch: fetchVia as any }, + ); + expect(result.error).toBeNull(); + expect(result.sent).toBe(true); + expect(result.status).toBe(201); + + const pending = await (await app.request(`${BASE}/messages`, { headers: auth })).json(); + expect(pending.messages).toHaveLength(1); + const [message] = pending.messages; + expect(message.origin).toBe('https://agenticjobs.work'); + expect(message.encoding).toBe('aes128gcm'); + // The stored body is ciphertext, not the payload. + expect(Buffer.from(message.body, 'base64url').toString()).not.toContain('New match'); + + const plain = new TextDecoder().decode(await decryptPush(fromB64u(message.body), keys)); + expect(plain).toBe(payload); + expect(notificationFromPayload(plain, message.origin)).toMatchObject({ + title: 'New match', body: 'rust / remote', url: 'https://agenticjobs.work/jobs/x', + }); + + const acked = await app.request(`${BASE}/ack`, { method: 'POST', headers: auth, body: JSON.stringify({ ids: [message.id] }) }); + expect((await acked.json()).acked).toBe(1); + expect((await (await app.request(`${BASE}/messages`, { headers: auth })).json()).messages).toHaveLength(0); + }); + + it('refuses a sender whose VAPID key is not the one the site subscribed with', async () => { + const { endpoint } = await subscribe(generateVapidKeys().publicKey); + const keys = await createSubscriptionKeys(); + const result = await sendPush( + { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }, + 'hi', + { keys: generateVapidKeys(), subject: 'mailto:x@example.com', fetch: fetchVia as any }, + ); + expect(result.status).toBe(403); + }); + + it('answers 404 for an unknown endpoint and 410 once unsubscribed, so senders clean up', async () => { + expect((await app.request(`${BASE}/nope`, { method: 'POST', headers: { ttl: '60' } })).status).toBe(404); + const { token } = await subscribe(null); + const del = await app.request(`${BASE}/subscriptions/${token}`, { method: 'DELETE', headers: auth }); + expect((await del.json()).deleted).toBe(true); + expect((await app.request(`${BASE}/${token}`, { method: 'POST', headers: { ttl: '60' } })).status).toBe(410); + }); + + it('enforces the protocol: TTL, encoding, size', async () => { + const vapid = generateVapidKeys(); + const { token } = await subscribe(vapid.publicKey); + const authorization = vapidHeader(vapid, 'https://tronbrowser.dev', 'mailto:x@example.com'); + const post = (headers: Record, body?: Uint8Array) => + app.request(`${BASE}/${token}`, { method: 'POST', headers: { authorization, ...headers }, body }); + expect((await post({})).status).toBe(400); // no TTL + expect((await post({ ttl: '60', 'content-encoding': 'aesgcm' }, new Uint8Array(10))).status).toBe(415); + expect((await post({ ttl: '60', 'content-encoding': 'aes128gcm' }, new Uint8Array(5000))).status).toBe(413); + expect((await post({ ttl: '60' })).status).toBe(201); // empty push is fine + expect((await app.request(`${BASE}/${token}`, { method: 'POST', headers: { ttl: '60' } })).status).toBe(401); // no VAPID + }); + + it('a newer push with the same Topic replaces the pending one', async () => { + const { token } = await subscribe(null); + for (let i = 0; i < 3; i++) { + await app.request(`${BASE}/${token}`, { method: 'POST', headers: { ttl: '60', topic: 'inbox' } }); + } + expect((await (await app.request(`${BASE}/messages`, { headers: auth })).json()).messages).toHaveLength(1); + }); + + it('refuses browser calls without a device secret, and a non-https origin', async () => { + expect((await app.request(`${BASE}/messages`)).status).toBe(401); + const res = await app.request(`${BASE}/subscriptions`, { + method: 'POST', headers: auth, body: JSON.stringify({ origin: 'http://evil.example' }), + }); + expect(res.status).toBe(400); + }); + + it('describes itself, so a settings page can check a custom push service', async () => { + const res = await app.request(BASE); + expect(await res.json()).toMatchObject({ service: 'tronbrowser-push', version: 1 }); + }); +}); + +describe('verifyVapid', () => { + it('rejects an audience for another push service', () => { + const vapid = generateVapidKeys(); + const header = vapidHeader(vapid, 'https://fcm.googleapis.com', 'mailto:x@example.com'); + const r = verifyVapid({ authorization: header }, 'https://tronbrowser.dev', vapid.publicKey); + expect(r).toMatchObject({ ok: false, status: 401 }); + }); +}); diff --git a/services/api/src/push/routes.ts b/services/api/src/push/routes.ts new file mode 100644 index 00000000..7a6d3303 --- /dev/null +++ b/services/api/src/push/routes.ts @@ -0,0 +1,311 @@ +// TronBrowser's own Web Push service, mounted at /api/1/push. +// +// Why it exists: ungoogled-chromium ships without Google's push service, and a +// page cannot choose another one, so pushManager.subscribe() fails for every +// site ("Registration failed - push service error"). The bundled extension +// replaces pushManager and registers here instead. The browser's push service +// is a TronBrowser setting; this is the default. +// +// Two sides: +// senders POST /api/1/push/:token RFC 8030 + 8291 + 8292, exactly what +// web-push / @profullstack/notifications send +// browsers POST /api/1/push/subscriptions (Bearer ) +// DELETE /api/1/push/subscriptions/:token +// GET /api/1/push/messages pending pushes +// POST /api/1/push/ack { ids } +// GET /api/1/push/connect WebSocket: live delivery +// +// We never see a payload in the clear: it arrives encrypted to a key only the +// browser holds, and is relayed as-is. +import { Hono } from 'hono'; +import type { Client } from '@libsql/client'; +import { createHash, randomBytes } from 'node:crypto'; +import { verifyVapid } from './vapid.js'; + +export const MAX_PAYLOAD = 4096; // RFC 8291 records; what every push service accepts +export const MAX_TTL = 28 * 24 * 3600; // FCM's ceiling +const MAX_SUBSCRIPTIONS_PER_DEVICE = 500; +const SENDS_PER_MINUTE = 120; // per subscription + +export interface PushMessage { + id: string; + token: string; + origin: string; + body: string; + encoding: string | null; +} + +type Socket = { send(data: string): void; close(): void }; + +export interface PushServiceOptions { + db: () => Client; + /** Public base of this service, e.g. https://tronbrowser.dev/api/1/push */ + publicBase: string; + upgradeWebSocket?: (handler: (c: any) => any) => any; + now?: () => number; +} + +const id = () => randomBytes(16).toString('base64url'); +const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); + +/** A device secret is 32+ random bytes, base64url; its sha256 is the device id. */ +function deviceIdFrom(secret: string | undefined): string | null { + if (!secret || !/^[A-Za-z0-9_-]{43,128}$/.test(secret)) return null; + return sha256(secret); +} + +function bearer(c: any): string | undefined { + return c.req.header('authorization')?.replace(/^Bearer\s+/i, '') || undefined; +} + +/** https origins only (plus http://localhost for development). */ +export function normalOrigin(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const u = new URL(value); + if (u.protocol === 'https:' || (u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return u.origin; + } catch { /* not a URL */ } + return null; +} + +/** An applicationServerKey as base64url, or null when it is not a P-256 point. */ +export function normalAppKey(value: unknown): string | null { + if (typeof value !== 'string') return null; + const raw = Buffer.from(value, 'base64url'); + return raw.length === 65 && raw[0] === 4 ? raw.toString('base64url') : null; +} + +export function pushService(opts: PushServiceOptions) { + const db = opts.db; + const now = opts.now ?? Date.now; + const base = opts.publicBase.replace(/\/$/, ''); + const audience = new URL(base).origin; + const sockets = new Map>(); + const sendCounts = new Map(); + const app = new Hono(); + + async function touchDevice(deviceId: string) { + await db().execute({ + sql: `INSERT INTO push_devices (id) VALUES (?) + ON CONFLICT(id) DO UPDATE SET last_seen_at = datetime('now')`, + args: [deviceId], + }); + } + + async function pending(deviceId: string): Promise { + const r = await db().execute({ + sql: `SELECT m.id, m.token, s.origin, m.body, m.encoding FROM push_messages m + JOIN push_subscriptions s ON s.token = m.token + WHERE m.device_id = ? AND m.expires_at > ? AND s.deleted_at IS NULL + ORDER BY m.created_at LIMIT 100`, + args: [deviceId, Math.floor(now() / 1000)], + }); + return r.rows.map((row: any) => ({ + id: row.id, token: row.token, origin: row.origin, body: row.body, encoding: row.encoding ?? null, + })); + } + + async function ack(deviceId: string, ids: unknown) { + const list = Array.isArray(ids) ? ids.filter((x) => typeof x === 'string').slice(0, 200) : []; + for (const messageId of list) { + await db().execute({ sql: 'DELETE FROM push_messages WHERE id = ? AND device_id = ?', args: [messageId, deviceId] }); + } + return list.length; + } + + function deliver(deviceId: string, message: PushMessage) { + const open = sockets.get(deviceId); + if (!open) return; + const frame = JSON.stringify({ type: 'messages', messages: [message] }); + for (const socket of open) { + try { socket.send(frame); } catch { /* the close handler tidies up */ } + } + } + + function overLimit(token: string): boolean { + const minute = Math.floor(now() / 60_000); + const seen = sendCounts.get(token); + if (!seen || seen.minute !== minute) { + if (sendCounts.size > 50_000) sendCounts.clear(); + sendCounts.set(token, { minute, count: 1 }); + return false; + } + seen.count += 1; + return seen.count > SENDS_PER_MINUTE; + } + + /* ---------- what this is, so a settings page can check a custom URL ---------- */ + app.get('/', (c) => + c.json({ + service: 'tronbrowser-push', + version: 1, + endpoint: `${base}/{token}`, + maxPayload: MAX_PAYLOAD, + maxTtl: MAX_TTL, + docs: 'https://tronbrowser.dev/push', + }), + ); + + /* ---------- browser side ---------- */ + app.post('/subscriptions', async (c) => { + const deviceId = deviceIdFrom(bearer(c)); + if (!deviceId) return c.json({ error: 'device secret required' }, 401); + const body = await c.req.json().catch(() => ({})); + const origin = normalOrigin(body.origin); + if (!origin) return c.json({ error: 'origin must be an https origin' }, 400); + const appKey = body.applicationServerKey == null ? null : normalAppKey(body.applicationServerKey); + if (body.applicationServerKey != null && !appKey) { + return c.json({ error: 'applicationServerKey must be a P-256 public key' }, 400); + } + await touchDevice(deviceId); + const count = await db().execute({ + sql: 'SELECT COUNT(*) AS n FROM push_subscriptions WHERE device_id = ? AND deleted_at IS NULL', + args: [deviceId], + }); + if (Number((count.rows[0] as any).n) >= MAX_SUBSCRIPTIONS_PER_DEVICE) { + return c.json({ error: 'too many subscriptions on this device' }, 429); + } + const token = randomBytes(32).toString('base64url'); + await db().execute({ + sql: 'INSERT INTO push_subscriptions (token, device_id, origin, app_key) VALUES (?, ?, ?, ?)', + args: [token, deviceId, origin, appKey], + }); + return c.json({ token, endpoint: `${base}/${token}` }, 201); + }); + + app.delete('/subscriptions/:token', async (c) => { + const deviceId = deviceIdFrom(bearer(c)); + if (!deviceId) return c.json({ error: 'device secret required' }, 401); + const token = c.req.param('token'); + const r = await db().execute({ + sql: `UPDATE push_subscriptions SET deleted_at = datetime('now') + WHERE token = ? AND device_id = ? AND deleted_at IS NULL`, + args: [token, deviceId], + }); + await db().execute({ sql: 'DELETE FROM push_messages WHERE token = ? AND device_id = ?', args: [token, deviceId] }); + return c.json({ deleted: r.rowsAffected > 0 }); + }); + + app.get('/messages', async (c) => { + const deviceId = deviceIdFrom(bearer(c)); + if (!deviceId) return c.json({ error: 'device secret required' }, 401); + await touchDevice(deviceId); + return c.json({ messages: await pending(deviceId) }); + }); + + app.post('/ack', async (c) => { + const deviceId = deviceIdFrom(bearer(c)); + if (!deviceId) return c.json({ error: 'device secret required' }, 401); + const body = await c.req.json().catch(() => ({})); + return c.json({ acked: await ack(deviceId, body.ids) }); + }); + + if (opts.upgradeWebSocket) { + // Browsers cannot set headers on a WebSocket, so the secret is the first + // frame: { type: 'hello', secret }. Then pending messages are sent, new ones + // as they arrive, and the client answers { type: 'ack', ids }. + app.get( + '/connect', + opts.upgradeWebSocket(() => { + let deviceId: string | null = null; + let self: Socket | null = null; + return { + async onMessage(event: any, ws: Socket) { + let frame: any; + try { frame = JSON.parse(String(event.data)); } catch { return; } + if (frame.type === 'hello' && !deviceId) { + deviceId = deviceIdFrom(frame.secret); + if (!deviceId) { ws.send(JSON.stringify({ type: 'error', error: 'device secret required' })); ws.close(); return; } + self = ws; + if (!sockets.has(deviceId)) sockets.set(deviceId, new Set()); + sockets.get(deviceId)!.add(ws); + await touchDevice(deviceId); + ws.send(JSON.stringify({ type: 'messages', messages: await pending(deviceId) })); + } else if (frame.type === 'ack' && deviceId) { + await ack(deviceId, frame.ids); + } else if (frame.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })); + } + }, + onClose() { + if (deviceId && self) { + const open = sockets.get(deviceId); + open?.delete(self); + if (open && open.size === 0) sockets.delete(deviceId); + } + }, + }; + }), + ); + } + + /* ---------- sender side (RFC 8030 §5) ---------- */ + app.post('/:token', async (c) => { + const token = c.req.param('token'); + const r = await db().execute({ + sql: 'SELECT device_id, origin, app_key, deleted_at FROM push_subscriptions WHERE token = ?', + args: [token], + }); + const sub = r.rows[0] as any; + // 404/410 is how senders learn to delete a subscription. + if (!sub) return c.json({ error: 'no such subscription' }, 404); + if (sub.deleted_at) return c.json({ error: 'subscription expired' }, 410); + + const vapid = verifyVapid( + { authorization: c.req.header('authorization'), cryptoKey: c.req.header('crypto-key') }, + audience, + sub.app_key ?? null, + now(), + ); + if (!vapid.ok) return c.json({ error: vapid.error }, vapid.status); + + const ttlHeader = c.req.header('ttl'); + if (ttlHeader == null || !/^\d+$/.test(ttlHeader.trim())) return c.json({ error: 'TTL header required' }, 400); + const ttl = Math.min(Number(ttlHeader), MAX_TTL); + + const body = new Uint8Array(await c.req.arrayBuffer()); + if (body.length > MAX_PAYLOAD) return c.json({ error: `payload over ${MAX_PAYLOAD} bytes` }, 413); + const encoding = c.req.header('content-encoding')?.toLowerCase() ?? null; + if (body.length > 0 && encoding !== 'aes128gcm') { + return c.json({ error: 'Content-Encoding must be aes128gcm (RFC 8291)' }, 415); + } + if (overLimit(token)) return c.json({ error: 'too many pushes to this subscription' }, 429); + + const urgency = /^(very-low|low|normal|high)$/.test(c.req.header('urgency') ?? '') ? c.req.header('urgency')! : 'normal'; + const topic = c.req.header('topic') && /^[A-Za-z0-9_-]{1,32}$/.test(c.req.header('topic')!) ? c.req.header('topic')! : null; + const message: PushMessage = { + id: id(), + token, + origin: sub.origin, + body: Buffer.from(body).toString('base64url'), + encoding: body.length > 0 ? encoding : null, + }; + const deviceId = String(sub.device_id); + + // Stored even with a live socket: it stays until the browser acks, so a + // push is not lost if the socket dies mid-send. TTL 0 = deliver now or never. + if (topic) { + await db().execute({ sql: 'DELETE FROM push_messages WHERE token = ? AND topic = ?', args: [token, topic] }); + } + if (ttl > 0 || sockets.has(deviceId)) { + await db().execute({ + sql: `INSERT INTO push_messages (id, token, device_id, body, encoding, urgency, topic, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + args: [message.id, token, deviceId, message.body, message.encoding, urgency, topic, + Math.floor(now() / 1000) + Math.max(ttl, 60)], + }); + } + deliver(deviceId, message); + + c.header('Location', `${base}/m/${message.id}`); + c.header('TTL', String(ttl)); + return c.body(null, 201); + }); + + /** Drop expired messages; call on a timer. */ + async function sweep() { + await db().execute({ sql: 'DELETE FROM push_messages WHERE expires_at <= ?', args: [Math.floor(now() / 1000)] }); + } + + return { app, sweep, sockets }; +} diff --git a/services/api/src/push/vapid.ts b/services/api/src/push/vapid.ts new file mode 100644 index 00000000..2550ddfb --- /dev/null +++ b/services/api/src/push/vapid.ts @@ -0,0 +1,81 @@ +// VAPID (RFC 8292) as a push service sees it: read the sender's key and JWT +// from the Authorization header and check the signature, audience and expiry. +// node:crypto only; no dependency. +import { createPublicKey, verify } from 'node:crypto'; + +export type VapidResult = + | { ok: true; key: string; subject: string | null } + | { ok: false; status: 401 | 403; error: string }; + +const b64u = (s: string) => Buffer.from(s, 'base64url'); + +/** A raw uncompressed P-256 point (65 bytes, 0x04 || x || y) as a KeyObject. */ +export function p256PublicKey(raw: Buffer) { + if (raw.length !== 65 || raw[0] !== 4) throw new Error('not an uncompressed P-256 point'); + return createPublicKey({ + key: { kty: 'EC', crv: 'P-256', x: raw.subarray(1, 33).toString('base64url'), y: raw.subarray(33).toString('base64url') }, + format: 'jwk', + }); +} + +/** + * The key and JWT from `Authorization: vapid t=, k=` (RFC 8292), or + * the older draft form `Authorization: WebPush ` + `Crypto-Key: p256ecdsa=` + * that some libraries still send. + */ +export function parseVapid(authorization: string | undefined, cryptoKey: string | undefined): { jwt: string; key: string } | null { + if (!authorization) return null; + const vapid = /^vapid\s+(.+)$/i.exec(authorization.trim()); + if (vapid) { + const params = Object.fromEntries( + vapid[1]!.split(',').map((part) => { + const i = part.indexOf('='); + return [part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim()]; + }), + ); + return params.t && params.k ? { jwt: params.t, key: params.k } : null; + } + const legacy = /^webpush\s+(\S+)$/i.exec(authorization.trim()); + const key = /p256ecdsa=([A-Za-z0-9_-]+)/.exec(cryptoKey ?? '')?.[1]; + return legacy && key ? { jwt: legacy[1]!, key } : null; +} + +/** + * Verify a push request's VAPID credentials against the endpoint's origin. + * `expectedKey` is the applicationServerKey the site subscribed with; a + * different key is a 403 (RFC 8292 §4.2), anything unreadable a 401. + */ +export function verifyVapid( + headers: { authorization?: string | undefined; cryptoKey?: string | undefined }, + audience: string, + expectedKey: string | null, + now = Date.now(), +): VapidResult { + const parsed = parseVapid(headers.authorization, headers.cryptoKey); + if (!parsed) { + return expectedKey + ? { ok: false, status: 401, error: 'VAPID authorization required' } + : { ok: true, key: '', subject: null }; + } + const [h, p, s] = parsed.jwt.split('.'); + if (!h || !p || !s) return { ok: false, status: 401, error: 'malformed JWT' }; + let header: any, claims: any, key; + try { + header = JSON.parse(b64u(h).toString()); + claims = JSON.parse(b64u(p).toString()); + key = p256PublicKey(b64u(parsed.key)); + } catch { + return { ok: false, status: 401, error: 'unreadable VAPID key or JWT' }; + } + if (header.alg !== 'ES256') return { ok: false, status: 401, error: 'JWT must be ES256' }; + const good = verify('sha256', Buffer.from(`${h}.${p}`), { key, dsaEncoding: 'ieee-p1363' }, b64u(s)); + if (!good) return { ok: false, status: 401, error: 'bad VAPID signature' }; + const seconds = Math.floor(now / 1000); + if (typeof claims.exp !== 'number' || claims.exp <= seconds) return { ok: false, status: 401, error: 'JWT expired' }; + if (claims.exp > seconds + 24 * 3600 + 300) return { ok: false, status: 401, error: 'JWT exp more than 24h ahead' }; + if (claims.aud !== audience) return { ok: false, status: 401, error: `JWT aud must be ${audience}` }; + if (expectedKey && parsed.key.replace(/=+$/, '') !== expectedKey) { + return { ok: false, status: 403, error: 'VAPID key does not match the subscription' }; + } + return { ok: true, key: parsed.key, subject: typeof claims.sub === 'string' ? claims.sub : null }; +} From d3d13a32bdfdc11a573b251971b39bdf7ebe286a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 10:36:14 +0000 Subject: [PATCH 2/2] Declare the chrome global in the push client test Co-Authored-By: Claude Opus 5.5 (1M context) --- services/api/src/push/client.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/api/src/push/client.test.js b/services/api/src/push/client.test.js index a2c7102e..be134582 100644 --- a/services/api/src/push/client.test.js +++ b/services/api/src/push/client.test.js @@ -1,3 +1,4 @@ +/* global chrome */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Hono } from 'hono'; import { createClient } from '@libsql/client';