Scrum4Me/actions/pairing.ts
Madhura68 a9616ff122 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>
2026-04-27 23:56:21 +02:00

143 lines
4.7 KiB
TypeScript

'use server'
// ST-1005: Server Actions voor de mobiele zijde van de QR-pairing-flow (M10).
//
// Aangeroepen door de Client Component op /m/pair zodra die het #id=…&s=…
// fragment uit de URL heeft geparsed. De mobiele gebruiker is hier al
// geauthenticeerd via de bestaande iron-session (de page zit achter de
// (app)/layout.tsx-guard).
//
// Volgt docs/patterns/server-action.md: getSession + Zod + demo-guard
// (uitsluitend op approvePairing — read-only en cancel mag iedereen).
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import { verifyToken } from '@/lib/auth/pairing'
const APPROVED_TTL_MS = 5 * 60 * 1000
const inputSchema = z.object({
pairingId: z.string().cuid(),
mobileSecret: z.string().min(40), // 32 bytes base64url ≈ 43 chars
})
type ActionFail = { ok: false; error: string }
type ApprovalView = {
ok: true
desktop_ua: string | null
desktop_ip: string | null
username: string
}
type PendingPairing = {
status: string
expires_at: Date
secret_hash: string
desktop_ua: string | null
desktop_ip: string | null
}
type LoadResult =
| { kind: 'error'; error: string }
| { kind: 'ok'; pairing: PendingPairing }
async function loadPendingPairing(
pairingId: string,
mobileSecret: string,
): Promise<LoadResult> {
const pairing = await prisma.loginPairing.findUnique({
where: { id: pairingId },
select: {
status: true,
expires_at: true,
secret_hash: true,
desktop_ua: true,
desktop_ip: true,
},
})
if (!pairing) return { kind: 'error', error: 'Pairing niet gevonden' }
if (pairing.expires_at < new Date()) return { kind: 'error', error: 'Pairing verlopen' }
if (pairing.status !== 'pending') return { kind: 'error', error: 'Pairing al afgehandeld' }
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
return { kind: 'error', error: 'Ongeldig pairing-geheim' }
}
return { kind: 'ok', pairing }
}
export async function getPairingForApproval(
pairingId: string,
mobileSecret: string,
): Promise<ApprovalView | ActionFail> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' }
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
if (result.kind === 'error') return { ok: false, error: result.error }
const me = await prisma.user.findUnique({
where: { id: session.userId },
select: { username: true },
})
return {
ok: true,
desktop_ua: result.pairing.desktop_ua,
desktop_ip: result.pairing.desktop_ip,
username: me?.username ?? '',
}
}
export async function approvePairing(
pairingId: string,
mobileSecret: string,
): Promise<{ ok: true } | ActionFail> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
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' }
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
if (result.kind === 'error') return { ok: false, error: result.error }
await prisma.loginPairing.update({
where: { id: parsed.data.pairingId },
data: {
status: 'approved',
user_id: session.userId,
approved_at: new Date(),
expires_at: new Date(Date.now() + APPROVED_TTL_MS),
},
})
// Postgres-trigger emit pg_notify('scrum4me_pairing', …) automatisch — de
// desktop-SSE in ST-1004 vangt het op. Geen revalidatePath nodig: deze page
// heeft geen server-state om te ververversen, en de mobiele tab gaat naar
// de "klaar"-state direct in de Client Component.
return { ok: true }
}
export async function cancelPairing(
pairingId: string,
mobileSecret: string,
): 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' }
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
if (result.kind === 'error') return { ok: false, error: result.error }
await prisma.loginPairing.update({
where: { id: parsed.data.pairingId },
data: { status: 'cancelled' },
})
return { ok: true }
}