Push Notifications
show.fm supports browser push notifications via the Web Push protocol with VAPID (Voluntary Application Server Identification) authentication.
Source: src/lib/notifications/push/
Architecture
VAPID Implementation
The VAPID (RFC 8292) implementation handles all cryptographic operations natively without external libraries.
Source: src/lib/notifications/push/vapid.ts
Key Format
VAPID keys use the P-256 (prime256v1) elliptic curve:
- Public key: 65-byte uncompressed point (base64url-encoded)
- Private key: 32-byte scalar (base64url-encoded or PEM-wrapped)
Both PEM-encoded and raw base64url key formats are supported.
Encryption Flow
- Generate ephemeral ECDH key pair (P-256)
- Derive shared secret via ECDH with recipient's p256dh key
- Extract key material using HKDF-SHA256 with auth secret as salt
- Encrypt payload with AES-128-GCM using derived content encryption key
- Build HTTP request with encrypted payload + VAPID Authorization header
JWT Authentication
Each push request includes a VAPID JWT:
Authorization: vapid t=<JWT>, k=<publicKey>- Algorithm: ES256 (ECDSA with P-256 and SHA-256)
- Expiration: 12 hours from creation
- Audience: Push service origin (e.g.,
https://fcm.googleapis.com) - Subject: Configured
VAPID_SUBJECT(mailto: or https: URL)
WebCrypto returns P1363, not DER
crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, ...) on P-256 returns a raw IEEE P1363 signature: exactly 64 bytes, r || s. That is already the JOSE ES256 format a JWT needs, so signatureToJose uses it directly. Node and workerd are both spec-compliant here.
Do not reintroduce an unconditional DER conversion. Passing the raw signature to a DER decoder throws Invalid ECDSA signature on every send, which is exactly what happened: push never delivered in any environment from launch until 2026-08 (issue #294). The derSignatureToJose fallback exists only for a hypothetical DER-returning runtime and is reached only when the signature is not 64 bytes.
The regression test is src/lib/notifications/push/__tests__/vapid.test.ts. It runs real crypto and must never mock vapid.ts: src/lib/notifications/__tests__/channels.test.ts mocks that module wholesale, which is why the defect was invisible to CI for so long.
Service Worker Notification Options
Source: src/service-worker.ts
The push handler passes a deliberately minimal option set: body, tag, and data. Two options are excluded on purpose.
Never pass requireInteraction
requireInteraction: true is unsupported on macOS, where Chrome hands off to the native notification centre. It is not ignored: it makes showNotification render nothing at all.
The handler previously set it for every immediate-priority notification, so chat.mention, booking.requested, episode.invited, team.invitation and all network.* pushes were silently lost. A service worker has its own console, so nothing appeared in the page console and the delivery row still read sent. It was found only by calling showNotification by hand in the worker's console:
showNotification('A', { body: 'plain' }); // renders
showNotification('C', { body: '…', tag: 'x', renotify: true }); // renders
showNotification('D', { body: '…', tag: 'x', requireInteraction: true }); // NOTHINGrenotify is excluded for a different reason: it only has meaning when a notification replaces an existing one sharing its tag, and tag is a unique per-notification id, so it can never fire. Reinstate it only alongside a tag scheme that intentionally coalesces.
Worker activation
The worker calls skipWaiting() on install and clients.claim() on activate, so a new version takes over on next navigation instead of waiting for every tab of the origin to close. Safe because this worker has no fetch handler and caches nothing. Without it a notification fix reaches an open tab only when the user happens to close all of them, which is exactly what happened after the requireInteraction fix shipped.
showNotificationSafely wraps the call: a rejection is logged and retried with { body } alone. userVisibleOnly: true obliges us to show something for every push, so a failing option must degrade to a plain notification rather than to silence. Regression cover is src/__tests__/service-worker.test.ts.
Push Payload
Source: src/lib/notifications/push/payloads.ts
interface PushNotificationPayload {
version: 1;
title: string; // Notification title
body: string; // Body text (max 140 chars, truncated)
url: string; // Click-through URL
tag: string; // Notification ID (for grouping/replacing)
notificationId: string; // For click tracking
deliveryId: string; // For click tracking
type: NotificationType; // e.g., 'chat.message'
priority: Priority; // immediate, normal, low
}The payload is serialised to JSON and encrypted before sending.
Subscription Management
Source: src/lib/notifications/push/subscriptions.ts
Subscription Schema
interface PushSubscriptionRecord {
id: string; // UUID
user_id: string; // Owner
endpoint: string; // Push service URL
p256dh_key: string; // Recipient's Diffie-Hellman key
auth_key: string; // Recipient's auth secret
device_label?: string; // User-provided device name
user_agent?: string; // Auto-captured browser UA
is_active: boolean; // Active flag
failure_count: number; // Consecutive failures
last_failure_at?: string; // Last failure timestamp
last_used_at?: string; // Last successful send
expires_at?: string; // Subscription expiration
created_at: string;
}Subscription Lifecycle
| Event | Action |
|---|---|
| User enables push | Upsert on endpoint (create or reactivate) |
| Successful delivery | Update last_used_at, reset failure_count |
| 404/410 from push service | Revoke: is_active = false, last_failure_at = now |
| 5xx/429 from push service | Increment failure_count, log but continue |
| User disables push | DELETE by endpoint or subscription ID |
Multi-Device Support
A user can have multiple active push subscriptions (one per browser/device). When sending a push notification, the executor iterates all active subscriptions and tracks results per subscription.
Client-Side API
Source: src/lib/notifications/push/client.ts
Browser Functions
// Check if push notifications are supported
isPushSupported(): boolean
// Register the service worker for push
registerNotificationServiceWorker(): Promise<ServiceWorkerRegistration>
// Subscribe to push notifications (requests permission, creates subscription, sends to backend)
subscribeToPushNotifications(vapidPublicKey: string): Promise<PushSubscription>
// Unsubscribe from push (revokes subscription, notifies backend)
unsubscribeFromPushNotifications(): Promise<void>Permission Flow
- Call
isPushSupported()to check browser compatibility - Call
subscribeToPushNotifications(vapidPublicKey):- Requests
Notification.permissionif not already granted - Subscribes via
pushManager.subscribe({ applicationServerKey, userVisibleOnly: true }) - Sends subscription to
POST /api/notifications/push-subscriptions
- Requests
- Service worker handles
pushevents and displays notifications - Click events report to
POST /api/notifications/push/click
Push Channel Executor
Source: src/lib/notifications/channels/push.ts
The push channel executor:
- Builds payload from notification content
- Fetches all active subscriptions for the recipient
- For each subscription:
- Encrypts payload using VAPID/aes128gcm
- Sends to the push service endpoint
- Handles responses:
- 201: Success, update
last_used_at - 404/410: Subscription expired, revoke it
- 429/5xx: Transient error, increment failure count
- 201: Success, update
- Returns aggregate result (success if any subscription received it)
A throw from the send path (bad key material, a crypto fault, a network error) carries no HTTP status. The caught message is therefore recorded twice: as responseBody on markSubscriptionFailure (landing in subscription_data.last_error_body) and in an errors array on the delivery's error_details and provider_metadata. Without that, such a failure persisted as statusCodes: [] with the cause recorded nowhere, which is how the VAPID signature-format defect stayed invisible in production (#294). Do not drop the caught error again.
Delivery Window
Every push is sent with TTL: 86400 (PUSH_TTL_SECONDS in channels/push.ts), so the push service holds an undelivered message for a day and delivers it when the device reconnects.
The sender defaults to 60 seconds and this channel previously passed nothing, so a device asleep or briefly disconnected for over a minute lost the notification permanently. Do not drop the explicit ttl again.
Priority Mapping
| Notification Priority | Push Urgency |
|---|---|
immediate | high |
normal | normal |
low | low |
Environment Variables
| Variable | Purpose |
|---|---|
VAPID_PUBLIC_KEY | P-256 public key for push subscription |
VAPID_PRIVATE_KEY | P-256 private key for JWT signing and encryption |
VAPID_SUBJECT | Contact URI (e.g., mailto:[email protected]) |
API Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/notifications/push-subscriptions | List user's subscriptions |
POST | /api/notifications/push-subscriptions | Create/upsert subscription |
DELETE | /api/notifications/push-subscriptions | Revoke by endpoint |
DELETE | /api/notifications/push-subscriptions/:id | Revoke by ID |
POST | /api/notifications/push/click | Record push click event |
See Notification API Reference for full details.
Related Documentation
- Notification System Overview -- Architecture and channels
- Delivery Pipeline -- How push fits in the execution flow
- Notification Executor Worker -- Worker that sends push notifications