Skip to content

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

  1. Generate ephemeral ECDH key pair (P-256)
  2. Derive shared secret via ECDH with recipient's p256dh key
  3. Extract key material using HKDF-SHA256 with auth secret as salt
  4. Encrypt payload with AES-128-GCM using derived content encryption key
  5. 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:

js
showNotification('A', { body: 'plain' }); // renders
showNotification('C', { body: '…', tag: 'x', renotify: true }); // renders
showNotification('D', { body: '…', tag: 'x', requireInteraction: true }); // NOTHING

renotify 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

typescript
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

typescript
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

EventAction
User enables pushUpsert on endpoint (create or reactivate)
Successful deliveryUpdate last_used_at, reset failure_count
404/410 from push serviceRevoke: is_active = false, last_failure_at = now
5xx/429 from push serviceIncrement failure_count, log but continue
User disables pushDELETE 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

typescript
// 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

  1. Call isPushSupported() to check browser compatibility
  2. Call subscribeToPushNotifications(vapidPublicKey):
    • Requests Notification.permission if not already granted
    • Subscribes via pushManager.subscribe({ applicationServerKey, userVisibleOnly: true })
    • Sends subscription to POST /api/notifications/push-subscriptions
  3. Service worker handles push events and displays notifications
  4. Click events report to POST /api/notifications/push/click

Push Channel Executor

Source: src/lib/notifications/channels/push.ts

The push channel executor:

  1. Builds payload from notification content
  2. Fetches all active subscriptions for the recipient
  3. 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
  4. 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 PriorityPush Urgency
immediatehigh
normalnormal
lowlow

Environment Variables

VariablePurpose
VAPID_PUBLIC_KEYP-256 public key for push subscription
VAPID_PRIVATE_KEYP-256 private key for JWT signing and encryption
VAPID_SUBJECTContact URI (e.g., mailto:[email protected])

API Endpoints

MethodPathPurpose
GET/api/notifications/push-subscriptionsList user's subscriptions
POST/api/notifications/push-subscriptionsCreate/upsert subscription
DELETE/api/notifications/push-subscriptionsRevoke by endpoint
DELETE/api/notifications/push-subscriptions/:idRevoke by ID
POST/api/notifications/push/clickRecord push click event

See Notification API Reference for full details.

Internal documentation - Not for public distribution