/** * public/sw-push.js — Pure push notification helpers (no SW globals). * * This file is imported by public/sw.js (module service worker) and tested * directly by test/sw-push.test.ts. It must never reference `self`, `clients`, * or any other SW-specific global — pure functions only. * * Implements the §3.3 PushPayload contract (one shape; FE reads `cls`). * SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking). * SEC-C1: capability token is forwarded in the decision body; Origin + token * validation happens server-side at POST /hook/decision. */ /** @type {Record} */ const TITLES = { 'needs-input': 'Approval Needed', done: 'Task Complete', stuck: 'Task Stuck', budget: 'Cost Budget Reached', } /** * Build a Notification title + options from an incoming push payload. * * @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload * @returns {{ title: string, options: Record }} */ export function buildPushNotification(payload) { const { sessionId, toolName, detail, token, cls } = payload const title = TITLES[cls] ?? 'Web Terminal' // Body: prefer explicit detail, fall back to toolName hint, else omit. const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined) /** @type {Record} */ const options = { tag: sessionId, // SEC-M6: replaces any previous notification for this session body, data: { sessionId, token, cls }, requireInteraction: cls === 'needs-input', // Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3) actions: cls === 'needs-input' ? [ { action: 'allow', title: 'Allow' }, { action: 'deny', title: 'Deny' }, ] : [], } return { title, options } } /** * Resolve a notificationclick event into an opaque result. * The caller (sw.js) executes the result: * kind='decision' → POST /hook/decision with result.body (SEC-C1) * kind='focus' → openWindow / focus existing tab at result.url * * @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification * @param {string | null | undefined} action — event.action from notificationclick * @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }} */ export function resolveNotificationClick(notification, action) { const data = notification?.data ?? {} const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data) if (action === 'allow' || action === 'deny') { // SEC-C1: include capability token so server can authenticate this decision. // Origin header is added automatically by fetch() with credentials:'same-origin'. return { kind: 'decision', body: JSON.stringify({ sessionId, decision: action, token }), } } // Default (body click, empty string, undefined, null) → focus/open terminal session return { kind: 'focus', url: `/?session=${encodeURIComponent(sessionId)}`, } }