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 <noreply@anthropic.com>
This commit is contained in:
parent
b91233092b
commit
cb784b8102
|
|
@ -1,186 +1,39 @@
|
||||||
/**
|
/**
|
||||||
* Notification REST API — mounted at /api/notifications
|
* 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 { Hono } from "hono";
|
||||||
import { verifyToken, extractToken } from "./auth";
|
|
||||||
import {
|
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||||
getUserNotifications,
|
|
||||||
getUnreadCount,
|
|
||||||
markNotificationRead,
|
|
||||||
markAllNotificationsRead,
|
|
||||||
dismissNotification,
|
|
||||||
getNotificationPreferences,
|
|
||||||
upsertNotificationPreferences,
|
|
||||||
savePushSubscription,
|
|
||||||
deletePushSubscriptionByEndpoint,
|
|
||||||
} from "../src/encryptid/db";
|
|
||||||
|
|
||||||
export const notificationRouter = new Hono();
|
export const notificationRouter = new Hono();
|
||||||
|
|
||||||
// ── Auth helper ──
|
// Proxy all notification requests to encryptid
|
||||||
async function requireAuth(req: Request) {
|
notificationRouter.all("/*", async (c) => {
|
||||||
const token = extractToken(req.headers);
|
const path = `/api/notifications${c.req.path === "/" ? "" : c.req.path}`;
|
||||||
if (!token) return null;
|
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 {
|
try {
|
||||||
return await verifyToken(token);
|
const res = await fetch(targetUrl, {
|
||||||
} catch {
|
method: c.req.method,
|
||||||
return null;
|
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",
|
||||||
// ── 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,
|
|
||||||
});
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: res.headers });
|
||||||
return c.json({ notifications });
|
} catch (e: any) {
|
||||||
} catch (err) {
|
console.error("[notifications proxy] Failed to reach encryptid:", e?.message);
|
||||||
console.error("[notifications] GET / failed:", err instanceof Error ? err.message : err);
|
// Return safe fallbacks instead of hanging
|
||||||
return c.json({ notifications: [] });
|
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 });
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -797,11 +797,13 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
if (immediate) { doSave(); } else { _tabSaveTimer = setTimeout(doSave, 500); }
|
||||||
// Broadcast to other same-browser tabs
|
// Broadcast to other same-browser tabs
|
||||||
if (_tabChannel) {
|
if (_tabChannel) {
|
||||||
_tabChannel.postMessage({
|
try {
|
||||||
type: 'tabs-sync',
|
_tabChannel.postMessage({
|
||||||
layers: layers,
|
type: 'tabs-sync',
|
||||||
closed: [..._closedModuleIds],
|
layers: layers,
|
||||||
});
|
closed: [..._closedModuleIds],
|
||||||
|
});
|
||||||
|
} catch(e) { /* channel may be closed during navigation */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,15 @@ import {
|
||||||
getTrustScoresByAuthority,
|
getTrustScoresByAuthority,
|
||||||
listAllUsersWithTrust,
|
listAllUsersWithTrust,
|
||||||
sql,
|
sql,
|
||||||
|
getUserNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
dismissNotification,
|
||||||
|
getNotificationPreferences,
|
||||||
|
upsertNotificationPreferences,
|
||||||
|
savePushSubscription,
|
||||||
|
deletePushSubscriptionByEndpoint,
|
||||||
getUserUPAddress,
|
getUserUPAddress,
|
||||||
setUserUPAddress,
|
setUserUPAddress,
|
||||||
getUserByUPAddress,
|
getUserByUPAddress,
|
||||||
|
|
@ -1245,6 +1254,146 @@ app.put('/api/user/tabs/:spaceSlug', async (c) => {
|
||||||
return c.json({ success: true });
|
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
|
// ACCOUNT SETTINGS ENDPOINTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue