From a9616ff122109fb1b32cc4cc012507ddb106c1e6 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 23:56:21 +0200 Subject: [PATCH] fix(M10): close pair/stream race + demo-block on cancelPairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twee P1's uit code-review: (1) pair/stream race: de findUnique die de pairing-status leest gebeurde vóór LISTEN actief was. Als de mobiel approvet tussen die query en LISTEN: pg_notify fired in dat venster gaat verloren (Postgres queuet niet voor abonnees die nog niet listen) én was de eerder gelezen status stale. De catch-up state- event emitte dus 'pending' terwijl de DB inmiddels 'approved' was, en de desktop bleef hangen tot expiry. Tweede findUnique toegevoegd ná LISTEN actief is: het venster sluit, omdat elke approve na dat punt via de notify-handler doorkomt. Aanvullend op de eerdere client-side fix die 'state' events nu ook routeert (commit d6e71f9). (2) cancelPairing demo-block: cancel was een DB-write zonder demo-guard, in tegenspraak met de "demo = 403 op writes"-regel. Demo-blokkade toegevoegd; bestaande test omgedraaid naar 'wordt geblokkeerd, geen DB-write'. Quality gates: lint 0 errors, tsc clean, vitest 139/139. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/pairing.test.ts | 9 ++++---- actions/pairing.ts | 2 ++ app/api/auth/pair/stream/[pairingId]/route.ts | 22 ++++++++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/__tests__/actions/pairing.test.ts b/__tests__/actions/pairing.test.ts index 47b1db0..da8d92f 100644 --- a/__tests__/actions/pairing.test.ts +++ b/__tests__/actions/pairing.test.ts @@ -159,13 +159,12 @@ describe('actions/pairing', () => { expect(arg.data.status).toBe('cancelled') }) - it('demo-user mag annuleren', async () => { + it('demo-user wordt geblokkeerd, geen DB-write', async () => { mockGetSession.mockResolvedValue(SESSION_DEMO) - mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) - mockPrisma.loginPairing.update.mockResolvedValue({}) - const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET) - expect(res).toEqual({ ok: true }) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() }) }) }) diff --git a/actions/pairing.ts b/actions/pairing.ts index 6f25c8a..6fedd3f 100644 --- a/actions/pairing.ts +++ b/actions/pairing.ts @@ -126,6 +126,8 @@ export async function cancelPairing( ): Promise<{ ok: true } | ActionFail> { const session = await getSession() if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + // Cancel is een DB-write — onder de demo-write-block-regel. + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } diff --git a/app/api/auth/pair/stream/[pairingId]/route.ts b/app/api/auth/pair/stream/[pairingId]/route.ts index 9ad5b53..049044d 100644 --- a/app/api/auth/pair/stream/[pairingId]/route.ts +++ b/app/api/auth/pair/stream/[pairingId]/route.ts @@ -152,17 +152,27 @@ export async function GET( cleanup('pg error') }) - // Initial state — voorkomt race waarbij approve net vóór SSE-open valt + // Initial state — dicht de race tussen pair/start en SSE-open. De + // *eerste* findUnique (voor cookie-validatie) gebeurde vóór LISTEN + // actief was; als de mobiel tussen die query en LISTEN approvet is + // de pg_notify verloren (Postgres queuet niet) én is de eerder + // gelezen status stale. Lees daarom de status hier opnieuw — nu LISTEN + // wel actief is, dus alle approvals na dit punt komen via de notify- + // handler door. + const fresh = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { status: true }, + }) + const currentStatus = fresh?.status ?? pairing.status + enqueue( `event: state\ndata: ${JSON.stringify({ pairing_id: pairingId, - status: pairing.status, + status: currentStatus, })}\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}`) + if (TERMINAL_STATUSES.has(currentStatus)) { + await cleanup(`already-${currentStatus}`) return }