diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a408173..5c8625a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,10 +8,12 @@ import { meRouter } from './routes/me.js' import { eventsRouter } from './routes/events.js' import { sseRouter } from './routes/sse.js' import { listsRouter, listItemsRouter } from './routes/lists.js' +import { pushRouter } from './routes/push.js' import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js' import { devAuthBypass } from './auth/devBypass.js' import { startBrokerPoller } from './broker/poller.js' import { startOutboxWorker } from './broker/outboxWorker.js' +import webpush from 'web-push' export const app = new Hono() @@ -64,6 +66,7 @@ app.route('/api/events', eventsRouter) app.route('/api/sse', sseRouter) app.route('/api/lists', listsRouter) app.route('/api/list-items', listItemsRouter) +app.route('/api/push', pushRouter) // WR-04: background worker startup (cron schedules) moved into the isMainModule() // guard below. Calling them at top level registered real node-cron schedules whenever @@ -105,6 +108,18 @@ function isMainModule(): boolean { // (not imported in tests). WR-04: gating the cron schedules here keeps them out of the // test process. if (isMainModule()) { + // Configure VAPID credentials for web-push before starting background workers. + // VAPID_SUBJECT must be a mailto: or https: URL identifying the operator. + // The private key is NEVER served to clients; it signs push requests server-side only. + const vapidSubject = process.env.VAPID_SUBJECT ?? '' + const vapidPublicKey = process.env.VAPID_PUBLIC_KEY ?? '' + const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY ?? '' + if (vapidSubject && vapidPublicKey && vapidPrivateKey) { + webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey) + } else { + console.warn('[startup] VAPID env vars not set — push notifications will fail. Set VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY.') + } + // Start the CalDAV broker poller (5-min cron, D-13 ctag change-detection). // Runs in the background — errors are caught and logged per-credential (T-03-04). startBrokerPoller() diff --git a/apps/api/src/routes/push.ts b/apps/api/src/routes/push.ts new file mode 100644 index 0000000..b453dd2 --- /dev/null +++ b/apps/api/src/routes/push.ts @@ -0,0 +1,139 @@ +/** + * 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) + 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) + return c.json({ error: 'Service unavailable' }, 503) + } +}) diff --git a/apps/api/tests/routes/push.test.ts b/apps/api/tests/routes/push.test.ts index e832012..34af350 100644 --- a/apps/api/tests/routes/push.test.ts +++ b/apps/api/tests/routes/push.test.ts @@ -103,15 +103,15 @@ describe('GET /api/push/vapid-public-key', () => { describe('POST /api/push/subscription', () => { it('returns 401 when unauthenticated', async () => { - // Temporarily override the mock to simulate unauthenticated state - vi.mocked(vi.getMockImplementation).mockImplementation?.(() => undefined) - - // Replace devBypass mock to inject no user (simulate 401) + // Simulate unauthenticated state: devBypass injects no user (passthrough only), + // and OIDC getAuth returns null — so resolveUserId returns null → 401. vi.doMock('../../src/auth/devBypass.js', () => ({ devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), })) vi.doMock('../../src/auth/middleware.js', () => ({ getAuth: () => null, + oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), })) const { app: freshApp } = await import('../../src/index.js?v=unauth')