TIP-005: OS Notifications via Encrypted Web Push

FieldValue
NumberTIP-005
TitleOS Notifications via Encrypted Web Push
StatusDraft (implemented, pending real-device verification)
AuthorJussi Rytkönen
Created2026-06-05

Summary

Tally can now raise OS-level notifications (lock screen / notification tray) when a peer message arrives while the app is closed. It uses the standard Web Push stack (VAPID + the browser vendor's push service) but keeps the notification text end-to-end encrypted: the relay that triggers the push never learns what the notification says.

Every inbound event that shows an in-app toast can also raise an OS notification; the user picks which in Settings → Notifications. Three are on by default, as they were before per-event choice existed:

The rest are off by default; related types share one setting (a payment request answered or cancelled → "Payment request response"; a friend request accepted or rejected → "Friend request response"). The settings, their types and defaults are OS_NOTIFY_SETTINGS in app/js/notify/notification-policy.js. Transport traffic (sync, receipts) and notification_prefs itself never notify.


Notification layers

The app surfaces "something happened" through four layers. This TIP adds layer 4 and centralizes the layer-4 routing decision in app/js/notify/notification-policy.js:

#LayerWhereLifetime / audience
1Logslogs page (derived from the ledger)every event; OS-notified types highlighted
2In-app toastsjs/ui/notifications.jswhile app open & focused; transient
3Actionable boxesfriend page (derived from app state)persists while the prompting state exists
4OS notificationsWeb Push → service workerreaches the user when the app is closed

Adding a new OS-notifiable event is a type in OS_NOTIFY_SETTINGS plus a HINT_BUILDERS function in the policy module.

When the app is open but not in view (another tab in front, minimised), the device is online, so the relay doesn't push. notify/local-os-notification.js then raises the OS notification from the page, under the same per-event choice and only on a device that opted in.


Per-event choice

Browsers forbid silent pushes: a subscription promises userVisibleOnly, and a push that shows nothing gets a browser-made notification (Chrome), a smaller quota (Firefox) or a revoked subscription (Safari). So the recipient's service worker can't drop unwanted events — the filter has to sit upstream.

The sender applies it. The recipient publishes its choice as a notification_prefs ledger message ({ enabled: [types] }) to every peer-eligible friend, plus a self-addressed copy that self-sync carries to its own devices. A sender reads the latest one by the recipient from its own ledger (peerOsNotifyTypes in the realtime snapshot) and attaches a push hint only for enabled types; the relay still pushes only envelopes that carry a hint. A new friend gets the choice when the friendship is accepted; a peer with no entry (stranger's friend request, older app) is treated as having the defaults.

The relay's view is unchanged: it never learns the event type. Friends do learn which events their friend gets notified about.


Why encrypted hints

A normal Web Push lets the application server put the notification text in the payload. Here the relay is semi-trusted (it already only sees opaque envelope ciphertext), so we don't want it composing or reading notification text. Instead:

  1. The sender knows the human-readable context (its own name, the amount). When it queues an envelope for an offline recipient, it also builds a short hint ({ title, body }), encrypts it for the recipient with the same NIP-44 v2 scheme used for envelope bodies (crypto/peer-crypto.js), and attaches it as envelope.push_hint.
  2. The relay forwards push_hint (plus the plaintext from_user_id routing field) as the Web Push payload — only when no device of the recipient is online to receive the envelope over WebSocket.
  3. The recipient's service worker decrypts the hint locally using the private key from IndexedDB and the sender's public key, then calls showNotification. If anything is missing (no key, malformed payload), it falls back to a generic "You have new Tally activity".

The relay therefore learns that a notification was sent and to whom (it already knew both), but never the amount, the note, or the event type.


Components

Server

Client

The message id travels as the notification tag, so a push and a local notification for the same message replace each other.


Privacy & security notes


Open questions / future work

  1. Per-device choice. The per-event choice is per user, because senders can't tell a user's devices apart; the on/off toggle stays per device.
  2. Subscription persistence across server restarts. Subscriptions are in-memory and self-heal on client reconnect (like the envelope queue). A durable store would close the gap where a push is missed in the window between a restart and the next reconnect.
  3. Push service is the browser's. The endpoint (FCM for Chromium, Mozilla for Firefox, Apple for Safari) is fixed by the browser; a web app can't use another one. Shown read-only in the subpage.
  4. Real-device verification. The encrypted-hint round trip can't be fully exercised in the preview browser; needs a home-screen PWA on a real device with a live push service.