fix(M10): close pair/stream race + demo-block on cancelPairing

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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 23:56:21 +02:00
parent d6e71f915c
commit a9616ff122
3 changed files with 22 additions and 11 deletions

View file

@ -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()
})
})
})

View file

@ -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' }

View file

@ -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
}