From 2a0c6a512de6de668e37d8de7e68fbd229d5f54d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 22:42:07 +0200 Subject: [PATCH] feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/auth/pair/stream/[pairingId]: - runtime: 'nodejs', maxDuration: 300, dynamic: 'force-dynamic' - Auth via s4m_pair HttpOnly cookie (readPairCookie + verifyToken tegen desktop_token_hash); 401 zonder cookie of bij hash-mismatch, 404 als pairing onbekend, 410 als verlopen — geen geheim materiaal in URL of querystring - Hergebruikt LISTEN/NOTIFY-pattern uit app/api/realtime/solo/route.ts: ReadableStream + dedicated pg.Client + heartbeat 25s + hard-close 240s - Channel: scrum4me_pairing; filter notifies op pairing_id-match - Initial 'state'-event direct na connect met huidige status (voorkomt race waarbij approve net vóór SSE-open landt — desktop ziet 'm alsnog) - Auto-close zodra status consumed/cancelled binnenkomt - Fallback DIRECT_URL → DATABASE_URL (de eerste staat lokaal op een placeholder) Tests __tests__/api/pair-stream.test.ts (4 cases — auth-paden): - 401 zonder cookie (en geen DB-call gedaan) - 404 op onbekende pairingId - 410 op verlopen pairing - 401 op cookie/hash-mismatch Full-stream-test (LISTEN+notify-roundtrip) is een handmatige acceptatietest in ST-1008 — niet zinvol te mocken voor v1. Quality gates: lint 0 errors, tsc clean, vitest 121/121. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/pair-stream.test.ts | 84 ++++++++ app/api/auth/pair/stream/[pairingId]/route.ts | 191 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 __tests__/api/pair-stream.test.ts create mode 100644 app/api/auth/pair/stream/[pairingId]/route.ts diff --git a/__tests__/api/pair-stream.test.ts b/__tests__/api/pair-stream.test.ts new file mode 100644 index 0000000..12433fd --- /dev/null +++ b/__tests__/api/pair-stream.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the cookie helper directly — easier than mocking next/headers + reasoning +// over the cookies() async wrapper. The actual cookie wiring is tested in +// pair-start.test.ts. +const { mockReadPairCookie } = vi.hoisted(() => ({ + mockReadPairCookie: vi.fn(), +})) + +vi.mock('@/lib/auth/pair-cookie', () => ({ + readPairCookie: mockReadPairCookie, + setPairCookie: vi.fn(), + clearPairCookie: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { hashToken } from '@/lib/auth/pairing' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/auth/pair/stream/[pairingId]/route' + +const mockPrisma = prisma as unknown as { + loginPairing: { findUnique: ReturnType } +} + +function makeReq(): NextRequest { + // Minimaal NextRequest-shape voor de auth-paden — we komen niet aan signal/url toe + // omdat de auth-checks vóór de stream-setup falen. + return { signal: new AbortController().signal } as unknown as NextRequest +} + +const params = (id: string) => + ({ params: Promise.resolve({ pairingId: id }) }) as { params: Promise<{ pairingId: string }> } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/auth/pair/stream/[pairingId]', () => { + it('401 zonder s4m_pair-cookie', async () => { + mockReadPairCookie.mockResolvedValue(null) + const res = await GET(makeReq(), params('pair-x')) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toMatch(/cookie/i) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + }) + + it('404 als pairing onbekend is', async () => { + mockReadPairCookie.mockResolvedValue('whatever-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue(null) + const res = await GET(makeReq(), params('pair-onbekend')) + expect(res.status).toBe(404) + }) + + it('410 als pairing verlopen is', async () => { + mockReadPairCookie.mockResolvedValue('correct-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + desktop_token_hash: hashToken('correct-token'), + status: 'pending', + expires_at: new Date(Date.now() - 1000), + }) + const res = await GET(makeReq(), params('pair-verlopen')) + expect(res.status).toBe(410) + }) + + it('401 als cookie hashed naar andere desktop_token_hash', async () => { + mockReadPairCookie.mockResolvedValue('verkeerd-token') + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + desktop_token_hash: hashToken('echt-token'), + status: 'pending', + expires_at: new Date(Date.now() + 60_000), + }) + const res = await GET(makeReq(), params('pair-mismatch')) + expect(res.status).toBe(401) + }) +}) diff --git a/app/api/auth/pair/stream/[pairingId]/route.ts b/app/api/auth/pair/stream/[pairingId]/route.ts new file mode 100644 index 0000000..9ad5b53 --- /dev/null +++ b/app/api/auth/pair/stream/[pairingId]/route.ts @@ -0,0 +1,191 @@ +// ST-1004: Server-Sent Events stream voor de QR-pairing-flow (M10). +// +// De desktop opent deze stream direct na pair/start. Auth is via de HttpOnly +// `s4m_pair`-cookie die diezelfde start-call zette — geen iron-session nodig +// (de gebruiker is op dit punt nog niet geauthenticeerd) en geen secret in +// query-parameters. De pairingId in het pad is niet vertrouwelijk. +// +// Bouwt voort op het LISTEN/NOTIFY-patroon uit ST-802 +// (app/api/realtime/solo/route.ts) maar op channel `scrum4me_pairing`. +// +// Output: text/event-stream met +// - één `state`-event direct na connect met de huidige pairing-status (zo +// mist de desktop geen approve die net vóór de SSE-open landde) +// - `message`-events met de volledige notify-payload bij elke status-wijziging +// - heartbeat-comments elke 25s om proxy-timeouts te voorkomen +// +// Sluit zelf na 240s als safety-net (Vercel kapt na maxDuration), of zodra +// status `consumed` of `cancelled` doorkomt — desktop heeft dan geen reden +// meer om te luisteren. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { prisma } from '@/lib/prisma' +import { verifyToken } from '@/lib/auth/pairing' +import { readPairCookie } from '@/lib/auth/pair-cookie' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_pairing' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +interface NotifyPayload { + op: 'I' | 'U' + pairing_id: string + status: 'pending' | 'approved' | 'consumed' | 'cancelled' +} + +const TERMINAL_STATUSES = new Set(['consumed', 'cancelled']) + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ pairingId: string }> }, +) { + const { pairingId } = await params + + const desktopToken = await readPairCookie() + if (!desktopToken) { + return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 }) + } + + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { desktop_token_hash: true, status: true, expires_at: true }, + }) + if (!pairing) { + return Response.json({ error: 'Pairing niet gevonden' }, { status: 404 }) + } + if (pairing.expires_at < new Date()) { + return Response.json({ error: 'Pairing verlopen' }, { status: 410 }) + } + if (!verifyToken(desktopToken, pairing.desktop_token_hash)) { + return Response.json({ error: 'Ongeldige cookie' }, { status: 401 }) + } + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl || !directUrl.startsWith('postgres')) { + // .env.local heeft DIRECT_URL=http://localhost:3000 (placeholder); dan + // valt fallback ook nog terug op DATABASE_URL. + const fallback = process.env.DATABASE_URL + if (!fallback) { + return Response.json( + { error: 'DATABASE_URL niet geconfigureerd' }, + { status: 500 }, + ) + } + } + const connectionString = + directUrl && directUrl.startsWith('postgres') + ? directUrl + : process.env.DATABASE_URL! + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // controller al gesloten — negeren + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + try { + await pgClient.end() + } catch { + // ignore + } + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[pair/stream ${pairingId}] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[pair/stream] pg connect/listen failed:', err) + enqueue( + `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, + ) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (payload.pairing_id !== pairingId) return + enqueue(`data: ${msg.payload}\n\n`) + if (TERMINAL_STATUSES.has(payload.status)) { + cleanup(`terminal status: ${payload.status}`) + } + }) + + pgClient.on('error', (err) => { + console.error('[pair/stream] pg client error:', err) + cleanup('pg error') + }) + + // Initial state — voorkomt race waarbij approve net vóór SSE-open valt + enqueue( + `event: state\ndata: ${JSON.stringify({ + pairing_id: pairingId, + status: pairing.status, + })}\n\n`, + ) + // Pairing was misschien al consumed/cancelled tussen findUnique en LISTEN — + // sluit de stream meteen. + if (TERMINAL_STATUSES.has(pairing.status)) { + await cleanup(`already-${pairing.status}`) + return + } + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +}