const parsePushSubscribeBody = (body) => { if (!body || typeof body !== 'object') return null; const endpoint = body.endpoint; const keys = body.keys; const p256dh = keys?.p256dh; const auth = keys?.auth; if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null; if (typeof auth !== 'string' || auth.trim().length === 0) return null; return { endpoint: endpoint.trim(), keys: { p256dh: p256dh.trim(), auth: auth.trim() }, }; }; const parsePushUnsubscribeBody = (body) => { if (!body || typeof body !== 'object') return null; const endpoint = body.endpoint; if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; return { endpoint: endpoint.trim() }; }; export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000; export const registerNotificationRoutes = (app, dependencies) => { const { uiAuthController, ensurePushInitialized, ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, readSettingsFromDiskMigrated, writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, addOrUpdateApnsToken, removeApnsToken, updateUiVisibility, clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, getSessionActivitySnapshot, getSessionStateSnapshot, getSessionAttentionSnapshot, getSessionState, getSessionAttentionState, markSessionViewed, markSessionUnviewed, markUserMessageSent, setPushInitialized, setAutoAcceptSession, } = dependencies; const ensureSessionWatcher = async () => { if (typeof ensureGlobalWatcherStarted !== 'function') { return; } try { await ensureGlobalWatcherStarted(); } catch (error) { console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error); } }; app.get('/api/push/vapid-public-key', async (_req, res) => { try { await ensurePushInitialized(); const keys = await getOrCreateVapidKeys(); res.json({ publicKey: keys.publicKey }); } catch (error) { console.warn('[Push] Failed to load VAPID key:', error); res.status(500).json({ error: 'Failed to load push key' }); } }); app.post('/api/push/subscribe', async (req, res) => { await ensurePushInitialized(); await ensureSessionWatcher(); const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } const parsed = parsePushSubscribeBody(req.body); if (!parsed) { return res.status(400).json({ error: 'Invalid body' }); } const { endpoint, keys } = parsed; const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : ''; if (origin.startsWith('http://') || origin.startsWith('https://')) { try { const settings = await readSettingsFromDiskMigrated(); if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) { await writeSettingsToDisk({ ...settings, publicOrigin: origin, }); setPushInitialized(false); } } catch { } } const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined; await addOrUpdatePushSubscription( uiToken, { endpoint, p256dh: keys.p256dh, auth: keys.auth, }, req.headers['user-agent'], platform ); return res.json({ ok: true }); }); app.delete('/api/push/subscribe', async (req, res) => { await ensurePushInitialized(); const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } const parsed = parsePushUnsubscribeBody(req.body); if (!parsed) { return res.status(400).json({ error: 'Invalid body' }); } await removePushSubscription(uiToken, parsed.endpoint); return res.json({ ok: true }); }); // Native iOS APNs device token registration (mirrors /api/push/subscribe). The token // is a hex APNs device token from @capacitor/push-notifications, scoped to the UI // session like web-push subscriptions. app.post('/api/push/apns-token', async (req, res) => { await ensureSessionWatcher(); const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; if (!deviceToken) { return res.status(400).json({ error: 'Invalid body' }); } const platform = req.body?.platform === 'android' ? 'android' : 'ios'; // APNs environment the token belongs to: Xcode/dev-signed installs report 'sandbox', // TestFlight/App Store report 'production'. Absent (older clients, Android) → production. const environment = req.body?.environment === 'sandbox' ? 'sandbox' : 'production'; if (typeof addOrUpdateApnsToken === 'function') { await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, environment); } return res.json({ ok: true }); }); app.delete('/api/push/apns-token', async (req, res) => { const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; if (!deviceToken) { return res.status(400).json({ error: 'Invalid body' }); } if (typeof removeApnsToken === 'function') { await removeApnsToken(uiToken, deviceToken); } return res.json({ ok: true }); }); app.post('/api/push/visibility', async (req, res) => { const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } const body = req.body && typeof req.body === 'object' ? req.body : {}; const platform = typeof body.platform === 'string' ? body.platform : undefined; updateUiVisibility(uiToken, body.visible === true, platform); return res.json({ ok: true }); }); app.get('/api/push/visibility', (req, res) => { const uiToken = getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } return res.json({ ok: true, visible: isUiVisible(uiToken), }); }); app.get('/api/notifications/stream', async (req, res) => { await ensureSessionWatcher(); const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return; } res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders?.(); const clients = getUiNotificationClients(); clients.add(res); let closed = false; let heartbeatTimer = null; const cleanup = () => { if (closed) { return; } closed = true; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } clients.delete(res); }; req.on('close', cleanup); res.on('error', cleanup); const flushSse = () => { res.flush?.(); }; heartbeatTimer = setInterval(() => { if (closed || res.writableEnded || res.destroyed) { cleanup(); return; } try { res.write(':heartbeat\n\n'); flushSse(); } catch { cleanup(); } }, NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS); try { writeSseEvent(res, { type: 'openchamber:notification-stream-ready', properties: { uiToken }, }); flushSse(); } catch { cleanup(); } }); app.get('/api/session-activity', (_req, res) => { void ensureSessionWatcher(); res.json(getSessionActivitySnapshot()); }); app.get('/api/sessions/snapshot', async (_req, res) => { await ensureSessionWatcher(); res.json({ statusSessions: getSessionStateSnapshot(), attentionSessions: getSessionAttentionSnapshot(), serverTime: Date.now(), }); }); app.get('/api/sessions/status', async (_req, res) => { await ensureSessionWatcher(); const snapshot = getSessionStateSnapshot(); res.json({ sessions: snapshot, serverTime: Date.now(), }); }); app.get('/api/sessions/:id/status', async (req, res) => { await ensureSessionWatcher(); const sessionId = req.params.id; const state = getSessionState(sessionId); if (!state) { return res.status(404).json({ error: 'Session not found or no state available', sessionId, }); } return res.json({ sessionId, ...state, }); }); app.get('/api/sessions/attention', async (_req, res) => { await ensureSessionWatcher(); const snapshot = getSessionAttentionSnapshot(); res.json({ sessions: snapshot, serverTime: Date.now(), }); }); app.get('/api/sessions/:id/attention', async (req, res) => { await ensureSessionWatcher(); const sessionId = req.params.id; const state = getSessionAttentionState(sessionId); if (!state) { return res.status(404).json({ error: 'Session not found or no attention state available', sessionId, }); } return res.json({ sessionId, ...state, }); }); app.post('/api/sessions/:id/view', (req, res) => { const sessionId = req.params.id; const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; markSessionViewed(sessionId, clientId); // The user is engaging with the app, so the native push badge no longer // applies — reset it here too (not only on the visibility beacon), since // opening the app reliably marks the opened session viewed. if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, sessionId, viewed: true, }); }); app.post('/api/sessions/:id/unview', (req, res) => { const sessionId = req.params.id; const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; markSessionUnviewed(sessionId, clientId); return res.json({ success: true, sessionId, viewed: false, }); }); app.post('/api/sessions/:id/message-sent', (req, res) => { const sessionId = req.params.id; markUserMessageSent(sessionId); // Sending a message means the user is active in the app; reset the native // push badge so it counts only notifications since this engagement. if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, sessionId, messageSent: true, }); }); // Mirror client-side Permission Auto-Accept state to the server so it can // suppress permission notifications at the source (the 500ms debounce race // otherwise leaks notifications for auto-accepted permissions). app.post('/api/notifications/auto-accept', (req, res) => { const body = req.body && typeof req.body === 'object' ? req.body : {}; const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''; const enabled = body.enabled === true; if (!sessionId) { return res.status(400).json({ error: 'sessionId required' }); } if (typeof setAutoAcceptSession === 'function') { setAutoAcceptSession(sessionId, enabled); } return res.json({ success: true, sessionId, enabled }); }); };