From cb784b81028ff2413e341bfce828d6a5be3ec599 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 13:30:58 -0700 Subject: [PATCH] fix(notifications): proxy to encryptid instead of direct DB access The rspace container cannot resolve encryptid-db hostname, causing /api/notifications/count to 524 timeout on every 30s poll. Rewrites notification-routes.ts as an HTTP proxy to encryptid (which has DB access), adds notification API endpoints to encryptid server, and wraps BroadcastChannel.postMessage in try/catch to prevent uncaught errors during navigation. Co-Authored-By: Claude Opus 4.6 --- server/notification-routes.ts | 199 +++++----------------------------- server/shell.ts | 12 +- src/encryptid/server.ts | 149 +++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 178 deletions(-) diff --git a/server/notification-routes.ts b/server/notification-routes.ts index 33044da..3b718ab 100644 --- a/server/notification-routes.ts +++ b/server/notification-routes.ts @@ -1,186 +1,39 @@ /** * Notification REST API — mounted at /api/notifications * - * All endpoints require Bearer auth (same pattern as spaces.ts). + * Proxies all requests to the encryptid service which has direct DB access. + * The rspace container cannot reach the encryptid database directly. */ import { Hono } from "hono"; -import { verifyToken, extractToken } from "./auth"; -import { - getUserNotifications, - getUnreadCount, - markNotificationRead, - markAllNotificationsRead, - dismissNotification, - getNotificationPreferences, - upsertNotificationPreferences, - savePushSubscription, - deletePushSubscriptionByEndpoint, -} from "../src/encryptid/db"; + +const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; export const notificationRouter = new Hono(); -// ── Auth helper ── -async function requireAuth(req: Request) { - const token = extractToken(req.headers); - if (!token) return null; +// Proxy all notification requests to encryptid +notificationRouter.all("/*", async (c) => { + const path = `/api/notifications${c.req.path === "/" ? "" : c.req.path}`; + const search = new URL(c.req.url).search; + const targetUrl = `${ENCRYPTID_URL}${path}${search}`; + + const headers = new Headers(c.req.raw.headers); + headers.delete("host"); + try { - return await verifyToken(token); - } catch { - return null; - } -} - -// ── GET / — Paginated notification list ── -notificationRouter.get("/", async (c) => { - try { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const url = new URL(c.req.url); - const unreadOnly = url.searchParams.get("unread") === "true"; - const category = url.searchParams.get("category") || undefined; - const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 100); - const offset = Number(url.searchParams.get("offset")) || 0; - - const notifications = await getUserNotifications(claims.sub, { - unreadOnly, - category, - limit, - offset, + const res = await fetch(targetUrl, { + method: c.req.method, + headers, + body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, + // @ts-ignore duplex needed for streaming request bodies + duplex: "half", }); - - return c.json({ notifications }); - } catch (err) { - console.error("[notifications] GET / failed:", err instanceof Error ? err.message : err); - return c.json({ notifications: [] }); + return new Response(res.body, { status: res.status, headers: res.headers }); + } catch (e: any) { + console.error("[notifications proxy] Failed to reach encryptid:", e?.message); + // Return safe fallbacks instead of hanging + if (c.req.path === "/count") return c.json({ unreadCount: 0 }); + if (c.req.path === "/") return c.json({ notifications: [] }); + return c.json({ error: "Notification service unavailable" }, 503); } }); - -// ── GET /count — Lightweight unread count (polling fallback) ── -notificationRouter.get("/count", async (c) => { - try { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const count = await getUnreadCount(claims.sub); - return c.json({ unreadCount: count }); - } catch (err) { - console.error("[notifications] GET /count failed:", err instanceof Error ? err.message : err); - return c.json({ unreadCount: 0 }); - } -}); - -// ── PATCH /:id/read — Mark one notification as read ── -notificationRouter.patch("/:id/read", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const id = c.req.param("id"); - const ok = await markNotificationRead(id, claims.sub); - if (!ok) return c.json({ error: "Notification not found" }, 404); - return c.json({ ok: true }); -}); - -// ── POST /read-all — Mark all read (optional scope) ── -notificationRouter.post("/read-all", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - let spaceSlug: string | undefined; - let category: string | undefined; - try { - const body = await c.req.json(); - spaceSlug = body.spaceSlug; - category = body.category; - } catch { - // No body is fine — mark everything read - } - - const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); - return c.json({ ok: true, markedRead: count }); -}); - -// ── DELETE /:id — Dismiss/archive a notification ── -notificationRouter.delete("/:id", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const id = c.req.param("id"); - const ok = await dismissNotification(id, claims.sub); - if (!ok) return c.json({ error: "Notification not found" }, 404); - return c.json({ ok: true }); -}); - -// ── GET /preferences — Get notification preferences ── -notificationRouter.get("/preferences", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const prefs = await getNotificationPreferences(claims.sub); - return c.json({ preferences: prefs || { - emailEnabled: true, - pushEnabled: true, - quietHoursStart: null, - quietHoursEnd: null, - mutedSpaces: [], - mutedCategories: [], - digestFrequency: 'none', - }}); -}); - -// ── PATCH /preferences — Update notification preferences ── -notificationRouter.patch("/preferences", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const body = await c.req.json(); - const prefs = await upsertNotificationPreferences(claims.sub, body); - return c.json({ preferences: prefs }); -}); - -// ============================================================================ -// WEB PUSH ENDPOINTS -// ============================================================================ - -// ── GET /push/vapid-public-key — Return public VAPID key (no auth) ── -notificationRouter.get("/push/vapid-public-key", (c) => { - const key = process.env.VAPID_PUBLIC_KEY; - if (!key) return c.json({ error: "Push not configured" }, 503); - return c.json({ publicKey: key }); -}); - -// ── POST /push/subscribe — Save push subscription (auth required) ── -notificationRouter.post("/push/subscribe", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const body = await c.req.json(); - const { endpoint, keys } = body; - if (!endpoint || !keys?.p256dh || !keys?.auth) { - return c.json({ error: "Invalid subscription object" }, 400); - } - - await savePushSubscription({ - id: crypto.randomUUID(), - userDid: claims.sub, - endpoint, - keyP256dh: keys.p256dh, - keyAuth: keys.auth, - userAgent: c.req.header("user-agent"), - }); - - return c.json({ ok: true }); -}); - -// ── POST /push/unsubscribe — Remove push subscription (auth required) ── -notificationRouter.post("/push/unsubscribe", async (c) => { - const claims = await requireAuth(c.req.raw); - if (!claims) return c.json({ error: "Authentication required" }, 401); - - const body = await c.req.json(); - if (!body.endpoint) return c.json({ error: "endpoint required" }, 400); - - await deletePushSubscriptionByEndpoint(body.endpoint); - return c.json({ ok: true }); -}); diff --git a/server/shell.ts b/server/shell.ts index b131b84..cb5e75e 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -797,11 +797,13 @@ export function renderShell(opts: ShellOptions): string { if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); } // Broadcast to other same-browser tabs if (_tabChannel) { - _tabChannel.postMessage({ - type: 'tabs-sync', - layers: layers, - closed: [..._closedModuleIds], - }); + try { + _tabChannel.postMessage({ + type: 'tabs-sync', + layers: layers, + closed: [..._closedModuleIds], + }); + } catch(e) { /* channel may be closed during navigation */ } } } diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b89ff18..7719fc0 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -120,6 +120,15 @@ import { getTrustScoresByAuthority, listAllUsersWithTrust, sql, + getUserNotifications, + getUnreadCount, + markNotificationRead, + markAllNotificationsRead, + dismissNotification, + getNotificationPreferences, + upsertNotificationPreferences, + savePushSubscription, + deletePushSubscriptionByEndpoint, getUserUPAddress, setUserUPAddress, getUserByUPAddress, @@ -1245,6 +1254,146 @@ app.put('/api/user/tabs/:spaceSlug', async (c) => { return c.json({ success: true }); }); +// ============================================================================ +// NOTIFICATION API (read/manage — used by rspace proxy) +// ============================================================================ + +/** GET /api/notifications — paginated notification list */ +app.get('/api/notifications', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + try { + const url = new URL(c.req.url); + const notifications = await getUserNotifications(claims.sub, { + unreadOnly: url.searchParams.get('unread') === 'true', + category: url.searchParams.get('category') || undefined, + limit: Math.min(Number(url.searchParams.get('limit')) || 50, 100), + offset: Number(url.searchParams.get('offset')) || 0, + }); + return c.json({ notifications }); + } catch (err) { + console.error('[notifications] GET / failed:', err instanceof Error ? err.message : err); + return c.json({ notifications: [] }); + } +}); + +/** GET /api/notifications/count — lightweight unread count */ +app.get('/api/notifications/count', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + try { + const count = await getUnreadCount(claims.sub); + return c.json({ unreadCount: count }); + } catch (err) { + console.error('[notifications] GET /count failed:', err instanceof Error ? err.message : err); + return c.json({ unreadCount: 0 }); + } +}); + +/** PATCH /api/notifications/:id/read — mark one notification as read */ +app.patch('/api/notifications/:id/read', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const ok = await markNotificationRead(c.req.param('id'), claims.sub); + if (!ok) return c.json({ error: 'Notification not found' }, 404); + return c.json({ ok: true }); +}); + +/** POST /api/notifications/read-all — mark all read */ +app.post('/api/notifications/read-all', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + let spaceSlug: string | undefined; + let category: string | undefined; + try { + const body = await c.req.json(); + spaceSlug = body.spaceSlug; + category = body.category; + } catch { /* no body is fine */ } + + const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category }); + return c.json({ ok: true, markedRead: count }); +}); + +/** DELETE /api/notifications/:id — dismiss/archive */ +app.delete('/api/notifications/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const ok = await dismissNotification(c.req.param('id'), claims.sub); + if (!ok) return c.json({ error: 'Notification not found' }, 404); + return c.json({ ok: true }); +}); + +/** GET /api/notifications/preferences */ +app.get('/api/notifications/preferences', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const prefs = await getNotificationPreferences(claims.sub); + return c.json({ preferences: prefs || { + emailEnabled: true, pushEnabled: true, + quietHoursStart: null, quietHoursEnd: null, + mutedSpaces: [], mutedCategories: [], + digestFrequency: 'none', + }}); +}); + +/** PATCH /api/notifications/preferences */ +app.patch('/api/notifications/preferences', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const body = await c.req.json(); + const prefs = await upsertNotificationPreferences(claims.sub, body); + return c.json({ preferences: prefs }); +}); + +/** GET /api/notifications/push/vapid-public-key */ +app.get('/api/notifications/push/vapid-public-key', (c) => { + const key = process.env.VAPID_PUBLIC_KEY; + if (!key) return c.json({ error: 'Push not configured' }, 503); + return c.json({ publicKey: key }); +}); + +/** POST /api/notifications/push/subscribe */ +app.post('/api/notifications/push/subscribe', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const body = await c.req.json(); + const { endpoint, keys } = body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return c.json({ error: 'Invalid subscription object' }, 400); + } + + await savePushSubscription({ + id: crypto.randomUUID(), + userDid: claims.sub, + endpoint, + keyP256dh: keys.p256dh, + keyAuth: keys.auth, + userAgent: c.req.header('user-agent'), + }); + return c.json({ ok: true }); +}); + +/** POST /api/notifications/push/unsubscribe */ +app.post('/api/notifications/push/unsubscribe', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Authentication required' }, 401); + + const body = await c.req.json(); + if (!body.endpoint) return c.json({ error: 'endpoint required' }, 400); + + await deletePushSubscriptionByEndpoint(body.endpoint); + return c.json({ ok: true }); +}); + // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================