feat(05-04): push subscription API + VAPID startup wiring
- Create apps/api/src/routes/push.ts: GET /vapid-public-key, POST /subscription (upsert), DELETE /subscription (user-scoped) - Wire pushRouter at /api/push in index.ts - Call webpush.setVapidDetails() in isMainModule() guard before serve() - Fix broken vi.getMockImplementation scaffold bug in push.test.ts (Rule 1) - push.test.ts: all 4 tests GREEN
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<number | null> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -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<void>) => next(),
|
||||
}))
|
||||
vi.doMock('../../src/auth/middleware.js', () => ({
|
||||
getAuth: () => null,
|
||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
}))
|
||||
|
||||
const { app: freshApp } = await import('../../src/index.js?v=unauth')
|
||||
|
||||
Reference in New Issue
Block a user