/** * public/push.ts — Push subscribe/permission UI (N-push-ui, A1) * * Exports: * PushSupportStatus — union of all push readiness states * fetchVapidKey() — GET /push/vapid-key (null on 503/error) * checkPushSupport(vapidKey) — async, checks all preconditions (SEC-H8) * subscribePush(vapidKey) — request permission + SW subscribe + POST server * unsubscribePush(sub) — DELETE server + browser unsubscribe (best-effort) * mountPushToggle(container, opts?) — render 🔔 toggle widget * isPushMuted() / setPushMuted(muted) — in-app mute (A1-FR9, localStorage only) * * SEC-H8: isSecureContext is the first check in checkPushSupport; nothing push- * related executes in an insecure context. * Review #12: The localStorage mute is in-app-only. Global DND is server-side * (NOTIFY_DND env). They are independent. */ import { ICON_BELL } from './icons.js' /* ── Types ─────────────────────────────────────────────────────────────────── */ /** All possible push-readiness states. Drives UI rendering in mountPushToggle. */ export type PushSupportStatus = | 'unsupported' // SW / PushManager / Notification API unavailable in this browser | 'insecure-context' // page is not a secure context; needs HTTPS or Tailscale (SEC-H8) | 'vapid-missing' // server has no VAPID keys; GET /push/vapid-key returned 503 | 'permission-denied' // user blocked notifications in browser settings | 'available' // all checks pass, ready to subscribe (no active sub yet) | 'subscribed' // active push subscription already exists export interface MountPushToggleOpts { /** Called once after the status is resolved and the widget is rendered. */ onChange?: (status: PushSupportStatus) => void } /* ── In-app mute (A1-FR9) ──────────────────────────────────────────────────── */ /** localStorage key for the in-app mute preference. */ const PUSH_MUTE_KEY = 'web-terminal:push-muted' /** Read the in-app mute preference. Returns false on any storage error. */ export function isPushMuted(): boolean { try { return localStorage.getItem(PUSH_MUTE_KEY) === '1' } catch { return false } } /** Write the in-app mute preference. Silently ignores storage errors. */ export function setPushMuted(muted: boolean): void { try { if (muted) { localStorage.setItem(PUSH_MUTE_KEY, '1') } else { localStorage.removeItem(PUSH_MUTE_KEY) } } catch { // Ignore — private browsing or storage quota; best-effort } } /* ── fetchVapidKey ─────────────────────────────────────────────────────────── */ /** * Fetch the VAPID public key from the server. * Returns null when push is disabled (503) or on any error. */ export async function fetchVapidKey(): Promise { try { const res = await fetch('/push/vapid-key', { credentials: 'same-origin' }) if (!res.ok) return null const data: unknown = await res.json() if ( typeof data === 'object' && data !== null && 'publicKey' in data && typeof (data as Record)['publicKey'] === 'string' ) { return (data as { publicKey: string }).publicKey } return null } catch { return null } } /* ── checkPushSupport ──────────────────────────────────────────────────────── */ /** * Check the comprehensive push support status. Checks in order: * insecure-context → unsupported → vapid-missing → permission-denied * → subscribed → available * * SEC-H8: isSecureContext is the first check. */ export async function checkPushSupport(vapidKey: string | null): Promise { // SEC-H8: secure context is required for SW and Push API if (!window.isSecureContext) return 'insecure-context' // Require all three browser APIs — check values (not just property existence), // because jsdom sets properties to undefined rather than deleting them. const hasSW = Boolean(navigator.serviceWorker) // Cast via `unknown` first to avoid TS2352 (Window lacks an index signature). const winMap = window as unknown as Record const hasPushMgr = Boolean(winMap['PushManager']) // Capture Notification locally so we can access .permission safely below. const Notif = winMap['Notification'] as typeof Notification | undefined if (!hasSW || !hasPushMgr || !Notif) return 'unsupported' // Server must have VAPID keys configured if (vapidKey === null) return 'vapid-missing' // Check browser permission if (Notif.permission === 'denied') return 'permission-denied' // Check for an existing active subscription try { const registration = await navigator.serviceWorker.getRegistration() if (registration) { const subscription = await registration.pushManager.getSubscription() if (subscription) return 'subscribed' } } catch { // Cannot determine subscription state; fall through to 'available' } return 'available' } /* ── subscribePush ─────────────────────────────────────────────────────────── */ /** Convert a URL-safe base64 string to Uint8Array for applicationServerKey. */ function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = window.atob(base64) const output = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { output[i] = rawData.charCodeAt(i) ?? 0 } return output } /** * Subscribe to push notifications. * 1. Requests Notification permission (if not already granted). * 2. Gets the SW registration and calls pushManager.subscribe(). * 3. POSTs the PushSubscription to /push/subscribe. * Returns the PushSubscription on success, null on any failure. */ export async function subscribePush(vapidKey: string): Promise { try { const perm = await Notification.requestPermission() if (perm !== 'granted') return null const registration = await navigator.serviceWorker.getRegistration() if (!registration) return null const applicationServerKey = urlBase64ToUint8Array(vapidKey) const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // Cast to `ArrayBuffer` — Uint8Array.buffer is ArrayBufferLike which includes // SharedArrayBuffer, but DOM types require ArrayBuffer here. applicationServerKey: applicationServerKey.buffer as ArrayBuffer, }) const res = await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(subscription.toJSON()), }) if (!res.ok) { // Roll back: unsubscribe from browser so we stay in sync await subscription.unsubscribe() return null } return subscription } catch { return null } } /* ── unsubscribePush ───────────────────────────────────────────────────────── */ /** * Unsubscribe from push notifications. * Both operations are best-effort; individual errors are swallowed so the * other operation still runs. */ export async function unsubscribePush(subscription: PushSubscription): Promise { try { await fetch('/push/subscribe', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ endpoint: subscription.endpoint }), }) } catch { // Ignore server errors — still attempt browser unsubscribe } try { await subscription.unsubscribe() } catch { // Ignore browser errors } } /* ── DOM helpers ───────────────────────────────────────────────────────────── */ function el( tag: K, cls?: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag) if (cls) node.className = cls if (text !== undefined) node.textContent = text return node } /* ── mountPushToggle ───────────────────────────────────────────────────────── */ /** * Mount a 🔔 push subscribe/unsubscribe toggle into container. * * Behavior per status: * 'vapid-missing' → hides container (server has no VAPID keys) * 'insecure-context' → grayed 🔔 + "needs HTTPS/Tailscale" hint * 'unsupported' → grayed 🔔 + "not supported in this browser" hint * 'permission-denied' → grayed 🔔 + "blocked in browser settings" hint * 'available' → active button (Off state) to initiate subscribe * 'subscribed' → active button (On state) to initiate unsubscribe */ export function mountPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): void { void initPushToggle(container, opts) } async function initPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): Promise { const vapidKey = await fetchVapidKey() const status = await checkPushSupport(vapidKey) renderPushToggle(container, status, vapidKey, opts) } function renderPushToggle( container: HTMLElement, status: PushSupportStatus, vapidKey: string | null, opts?: MountPushToggleOpts, ): void { // Clear existing children while (container.firstChild) container.removeChild(container.firstChild) opts?.onChange?.(status) if (status === 'vapid-missing') { // Server push is disabled — hide the widget entirely container.style.display = 'none' return } container.style.display = '' if (status === 'insecure-context') { renderDisabled( container, 'Push notifications unavailable', 'Enable push: access via HTTPS or Tailscale', ) return } if (status === 'unsupported') { renderDisabled( container, 'Push notifications not supported', 'Push notifications not supported in this browser', ) return } if (status === 'permission-denied') { renderDisabled( container, 'Push notifications blocked', 'Notifications blocked — allow in browser settings', ) return } // 'available' or 'subscribed' — render a functional toggle button renderToggleButton(container, status === 'subscribed', vapidKey, opts) } function renderDisabled(container: HTMLElement, ariaLabel: string, hint: string): void { const wrap = el('span', 'push-toggle-disabled') const bell = el('span', 'push-bell push-bell-off') bell.innerHTML = ICON_BELL bell.setAttribute('aria-label', ariaLabel) const hintEl = el('span', 'push-hint', hint) wrap.append(bell, hintEl) container.append(wrap) } function renderToggleButton( container: HTMLElement, isSubscribed: boolean, vapidKey: string | null, opts?: MountPushToggleOpts, ): void { const btn = el( 'button', isSubscribed ? 'push-toggle-btn push-toggle-on' : 'push-toggle-btn push-toggle-off', ) btn.innerHTML = ICON_BELL btn.append(' ' + (isSubscribed ? 'On' : 'Off')) btn.dataset.tip = isSubscribed ? 'Notifications on — tap to turn off' : 'Notifications off — tap to turn on' btn.setAttribute('aria-pressed', isSubscribed ? 'true' : 'false') btn.addEventListener('click', () => { btn.disabled = true void handleToggleClick(container, vapidKey, isSubscribed, opts) }) container.append(btn) } async function handleToggleClick( container: HTMLElement, vapidKey: string | null, wasSubscribed: boolean, opts?: MountPushToggleOpts, ): Promise { if (wasSubscribed) { try { const registration = await navigator.serviceWorker.getRegistration() if (registration) { const sub = await registration.pushManager.getSubscription() if (sub) await unsubscribePush(sub) } } catch { // ignore } } else if (vapidKey !== null) { await subscribePush(vapidKey) } // Re-check status and re-render const newStatus = await checkPushSupport(vapidKey) renderPushToggle(container, newStatus, vapidKey, opts) }