/** * Push router — VAPID public-key delivery + subscription lifecycle (NOTIF-01/02/03). * * Security (threat model T-05-09, T-05-10, T-05-13): * - GET /vapid-public-key: public key only; sits under /api OIDC guard (PWA fetches post-login). * The VAPID public key is non-secret — the private key is NEVER served. * - POST /subscription: zod-validated body; userId from OIDC session (T-05-09 spoofing * mitigation — client body never sets userId). Upsert on endpoint to handle re-subscribe. * - DELETE /subscription: scoped WHERE userId = caller — cannot delete another member's * subscription (T-05-13 access control). * * Mounted under /api/push in index.ts. */ import { Hono } from 'hono'; import type { Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; import { pushSubscriptions } from '../db/schema.js'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; export const pushRouter = new Hono(); // --------------------------------------------------------------------------- // Auth helper — Duplicated per router (not extracted to shared module). // Copied verbatim from lists.ts per project convention. // // Resolution order: // 1. Dev-bypass path: c.get('user') is set by devAuthBypass() when DEV_AUTH_BYPASS=true. // 2. OIDC path: call getAuth(c). If null → unauthenticated, return null. // --------------------------------------------------------------------------- async function resolveUserId(c: Context): Promise { const devUser = c.get('user') as { id: number } | undefined; if (devUser) return devUser.id; const auth = await getAuth(c); if (!auth) return null; const iss = (auth.iss as string | undefined) ?? ''; const sub = auth.sub ?? ''; const displayName = deriveDisplayName(auth); const user = await upsertUser(iss, sub, displayName); return user?.id ?? null; } // --------------------------------------------------------------------------- // Zod schemas // --------------------------------------------------------------------------- /** * Subscribe body — validated per T-05-10 (input validation). * Bounds: endpoint URL max 2048, p256dh max 512 (unpadded base64 of 65-byte P-256 key), * auth max 256 (unpadded base64 of 16-byte auth secret). */ const subscribeSchema = z.object({ endpoint: z.string().url().max(2048), keys: z.object({ p256dh: z.string().min(1).max(512), auth: z.string().min(1).max(256), }), }); // --------------------------------------------------------------------------- // GET /api/push/vapid-public-key // // Serves the VAPID public key to the PWA. The public key is not a secret — it // is needed by the browser to compute the applicationServerKey for subscribe(). // The PRIVATE key is NEVER returned. // // Sits under /api OIDC guard (acceptable for v1 — only authenticated members can // subscribe to push anyway). // --------------------------------------------------------------------------- pushRouter.get('/vapid-public-key', (c) => { return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' }); }); // --------------------------------------------------------------------------- // POST /api/push/subscription // // Persists a push subscription row scoped to the authenticated user. // Upserts on endpoint (unique constraint) so re-subscribing from the same device // updates the ownership + keys without creating duplicate rows. // // Security: T-05-09 — userId derives from OIDC session, never from request body. // T-05-10 — zod subscribeSchema validates all fields before DB write. // --------------------------------------------------------------------------- pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => { const userId = await resolveUserId(c); if (!userId) return c.json({ error: 'Unauthorized' }, 401); const body = c.req.valid('json'); try { await db .insert(pushSubscriptions) .values({ userId, endpoint: body.endpoint, p256dh: body.keys.p256dh, auth: body.keys.auth, }) .onDuplicateKeyUpdate({ set: { userId, p256dh: body.keys.p256dh, auth: body.keys.auth, }, }); return c.json({ ok: true }, 201); } catch (err) { console.error( '[push/POST /subscription] DB operation failed:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } }); // --------------------------------------------------------------------------- // DELETE /api/push/subscription // // Removes all push subscription rows for the authenticated user. // Scoped to caller only — cannot delete another member's subscriptions (T-05-13). // --------------------------------------------------------------------------- pushRouter.delete('/subscription', async (c) => { const userId = await resolveUserId(c); if (!userId) return c.json({ error: 'Unauthorized' }, 401); try { await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId)); return c.json({ ok: true }); } catch (err) { console.error( '[push/DELETE /subscription] DB operation failed:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } });