Scrum4Me/docs/plans/M10-qr-pairing-login.md
Janpeter Visser 7e45bbdbc0
docs: AI-optimized docs restructure (Phases 1–8) (#61)
* docs(dialog-pattern): add generic entity-dialog spec

Introduceert docs/patterns/dialog.md als bron-of-truth voor elke
create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende
dataobject. Bevat 14 secties: uitgangspunten, stack, component-
architectuur, layout, validatie, drielaagse demo-policy, submission,
dialog-gedrag, theming, footer, triggers/URL-state, per-entiteit
profile-template, out-of-scope, en een verificatie-checklist.

Registreert het patroon in CLAUDE.md "Implementatiepatronen"-tabel
zodat Claude (en mensen) de spec verplicht raadplegen voor elke
nieuwe dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(dialog-pattern): convert task spec + add pbi/story entity-profiles

Reduceert docs/scrum4me-task-dialog.md van 507 naar ~140 regels: alle
gedeelde regels verhuisd naar docs/patterns/dialog.md, dit document
bevat nu alleen Task-specifieke velden, URL-pattern, status-veld,
server actions, triggers en bewuste out-of-scope-keuzes.

Voegt twee nieuwe entity-profielen toe voor bestaande dialogen:
- docs/scrum4me-pbi-dialog.md (PbiDialog: state-based, code+title-rij,
  PbiStatusSelect, geen delete in v1)
- docs/scrum4me-story-dialog.md (StoryDialog: state-based, header met
  status/priority badges, inline activity-log, demo-readonly-fallback,
  inline-delete-confirm i.p.v. AlertDialog)

Beide profielen documenteren expliciet de "Bekende gaps t.o.v.
generieke spec" zodat opvolgende PR's de afwijkingen kunnen
rechtzetten of bewust kunnen accorderen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Added pdevelopment docs

* docs(plans): add docs-restructure plan for AI-optimized lookup

Audit of existing 39 doc files (~10.700 lines) and a phased restructure
proposal aimed at minimising the tokens an AI agent has to read to find
the right reference. Captures resolved decisions on language (English),
ADR template (Nygard default with MADR escape-hatch), index generator
(node script), and folder taxonomy. Proposal status — fase 1 to follow.

* docs(adr): add ADR scaffolding (templates, README, meta-ADR)

Set up docs/adr/ as the canonical home for architecture decisions:

- templates/nygard.md — default four-section format (Status, Context,
  Decision, Consequences) for one-way-door decisions.
- templates/madr.md — MADR v4 with YAML front-matter and explicit
  Considered Options for decisions where rejected alternatives matter.
- README.md — naming convention (NNNN-kebab-case), template-selection
  guidance (Nygard default; MADR for auth, queue mechanics, agent
  integration), status lifecycle, and ADR roster.
- 0000-record-architecture-decisions.md — meta-ADR establishing the
  practice itself, in Nygard format.

Backfilling existing implicit decisions (base-ui-over-radix, float
sort_order, demo-user three-layer policy, etc.) is fase 6 of the
docs-restructure plan.

* feat(docs): add docs index generator + initial INDEX.md

scripts/generate-docs-index.mjs walks docs/**/*.md, parses YAML
front-matter (or first H1 fallback) and a Nygard-style ## Status
section, then writes docs/INDEX.md with grouped tables for ADRs,
Specs, Plans (with archive subsection), Patterns, and Other.

Pure Node 20 (no external deps); idempotent — running it twice
produces byte-identical output. Excludes adr/templates/, the ADR
README, INDEX.md itself, and any *_*.md sidecar file.

Wire-up:
- package.json: docs:index → node scripts/generate-docs-index.mjs

Initial run indexed 35 docs across the existing structure; the
generated INDEX.md is committed so the table is reviewable in the
PR before hooking generation into a pre-commit step.

* chore: ignore Obsidian vault and personal sidecar files

Add .obsidian/ (Obsidian vault config) and _*.md (personal sidecar
notes) to .gitignore so the docs/ tree can serve as canonical source
of truth while still being usable as an Obsidian vault for personal
authoring. The docs index generator already excludes the same _*.md
pattern from INDEX.md.

* docs(plans): add PBI bulk-create spec for docs-restructure

Machine-parseable spec for an executor that calls the scrum4me MCP
(create_pbi → create_story → create_task) to seed the docs-restructure
work into the DB.

- Section 1 (Context) is the PBI description; serves as task-context
  via mcp__scrum4me__get_claude_context.
- Section 2 lists the 6 resolved decisions (English, MD3+styling
  merged, solo-paneel merged, .Plans archived, Nygard ADR default,
  node index script).
- Section 3 records what already shipped on this branch so the
  executor doesn't duplicate the ADR scaffolding or index generator.
- Section 4 carries the structured YAML graph: 1 PBI, 8 stories
  (one per phase), 39 tasks. product_id is REPLACE_ME — fill before
  running.
- YAML validated with PyYAML; field schema sanity-checked.

* docs(junk-cleanup): remove stub patterns/test.md

* docs(junk-cleanup): archive .Plans/ to docs/plans/archive/

* docs(front-matter): add YAML front-matter to docs/ root

* docs(front-matter): add YAML front-matter to patterns/

* docs(front-matter): add YAML front-matter to plans + agent files

* docs(index): regenerate INDEX.md after front-matter pass

* docs(naming): drop scrum4me- prefix from doc filenames

* docs(naming): lowercase API.md and MD3 filenames

* docs(naming): rename plan file to kebab-case ASCII

* docs(naming): rename middleware.md to proxy.md (next 16)

* docs(naming): polish CLAUDE.md doc-index after renames

* docs(taxonomy): scaffold topical folders under docs/

* docs(taxonomy): move spec files into docs/specs/

* docs(taxonomy): move design/api/qa/backlog/assets into folders

* docs(taxonomy): move agent-instruction-audit into decisions/

* docs(split): break architecture.md into 6 topical files

* docs(split): merge solo-paneel-spec into specs/functional.md

* docs(split): merge md3-color-scheme into design/styling

* docs(trim): extract branch/commit rules into runbook

* docs(trim): extract MCP integration into runbook

* docs(adr): add 0001-base-ui-over-radix

* docs(adr): add 0002-float-sort-order

* docs(adr): add 0003-one-branch-per-milestone

* docs(adr): add 0004-status-enum-mapping

* docs(adr): add 0005-iron-session-over-nextauth

* docs(adr): add 0006-demo-user-three-layer-policy

* docs(adr): add 0007-claude-question-channel-design

* docs(adr): add 0008-agent-instructions-in-claude-md + update README index

* docs(index): regenerate after ADR 0001-0008

* docs(glossary): add docs/glossary.md

* chore(docs): regenerate INDEX.md in pre-commit hook

* docs(readme): link INDEX + glossary + agent instructions

* feat(docs): add doc-link checker script

* chore(docs): wire docs:check-links and docs npm scripts

* ci(docs): block merge on broken doc links

* docs(links): fix broken cross-references after restructure

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:21:59 +02:00

39 KiB
Raw Permalink Blame History

title status audience language last_updated applies_to
M10 — Password-loze inlog via QR-pairing active
maintainer
contributor
nl 2026-05-03
M10

M10 — Password-loze inlog via QR-pairing

Inloggen op een (publieke) desktop zonder wachtwoord: desktop toont QR, telefoon (al-ingelogd) scant en bevestigt expliciet, desktop is binnen 12 s ingelogd. Bouwt voort op M8 LISTEN/NOTIFY-infra met eigen channel scrum4me_pairing.

Beveiligingsuitgangspunt: geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis.

  • mobileSecret reist alleen via QR-fragment (#s=…) → mobile location.hash → POST-body
  • desktopToken reist alleen via HttpOnly cookie s4m_pair met Path=/api/auth/pair, Max-Age=120, SameSite=Lax
  • Twee gescheiden hashes in DB scheiden mobiel-bewijs (secret_hash) van desktop-bewijs (desktop_token_hash)

Backlog-entries: zie backlog.md § M10. Functional spec: zie functional.md § F-01b.

Implementatie-volgorde (commit-strategy uit CLAUDE.md):

  1. DB-laag — ST-1001 (schema + trigger)
  2. Auth helpers + sessie-uitbreiding — ST-1002
  3. API-laag — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
  4. Server actions + mobile UI — ST-1005
  5. Desktop UI — ST-1007
  6. Documentatie + acceptatietest — ST-1008

ST-1006 staat bij de API-laag (niet bij UI) omdat het een Route Handler is; ST-1005 levert tegelijk de server actions en de mobiele bevestigingspagina omdat die strak gekoppeld zijn.


ST-1001 — LoginPairing schema + Postgres-trigger

Bestanden

  • prisma/schema.prisma — nieuw model LoginPairing + back-relation op User
  • prisma/migrations/<timestamp>_add_login_pairing/migration.sql — model + trigger
  • vendor/scrum4me-submodule in repo mcp — schema-sync ná merge

Stappen

  1. Schema-uitbreiding:

    model LoginPairing {
      id                  String    @id @default(cuid())
      secret_hash         String    // sha256 hex van mobileSecret
      desktop_token_hash  String    // sha256 hex van desktopToken (HttpOnly cookie)
      status              String    // 'pending' | 'approved' | 'consumed' | 'cancelled'
      user_id             String?
      user                User?     @relation(fields: [user_id], references: [id], onDelete: SetNull)
      desktop_ua          String?   @db.VarChar(255)
      desktop_ip          String?   @db.VarChar(45)   // IPv6 max
      created_at          DateTime  @default(now())
      expires_at          DateTime
      approved_at         DateTime?
      consumed_at         DateTime?
    
      @@index([expires_at])
      @@index([status, expires_at])
      @@map("login_pairings")
    }
    

    Op User: login_pairings LoginPairing[] toevoegen.

  2. Migratie-SQL voegt naast de tabel ook trigger toe (mirror van notify_solo_change in prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql):

    CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$
    DECLARE payload jsonb;
    BEGIN
      payload := jsonb_build_object(
        'op', CASE TG_OP WHEN 'INSERT' THEN 'I' WHEN 'UPDATE' THEN 'U' ELSE 'D' END,
        'pairing_id', COALESCE(NEW.id, OLD.id),
        'status',     COALESCE(NEW.status, OLD.status)
      );
      PERFORM pg_notify('scrum4me_pairing', payload::text);
      RETURN COALESCE(NEW, OLD);
    END;
    $$ LANGUAGE plpgsql;
    
    CREATE TRIGGER login_pairings_notify
      AFTER INSERT OR UPDATE ON login_pairings
      FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();
    
  3. npx prisma migrate dev --name add_login_pairing.

Aandachtspunten

  • desktop_ip houdt op 45 tekens om IPv6 te accommoderen (xxxx:xxxx:…:255.255.255.255).
  • Geen index op user_id nodig voor v1 — er is geen lookup-pad "geef alle pairings van user X" (komt pas bij remote-revoke in M+1).
  • Trigger emit ook bij DELETE niet nodig — pairings worden niet gedelete'd, ze gaan naar consumed/cancelled.
  • vendor/scrum4me-submodule in mcp moet ná merge op main direct gesynced worden, anders breekt de wekelijkse drift-check (trig_015FFUnxjz9WMuhhWNGBQKFD). Dit was ook een aandachtspunt bij ST-901.

Verificatie

  • npx prisma migrate dev slaagt
  • npx prisma validate zonder fouten
  • psql $DIRECT_URL -c "LISTEN scrum4me_pairing;" toont payload bij INSERT INTO login_pairings(...) VALUES(...)
  • prisma studio toont tabel met beide hash-kolommen NOT NULL

Bestanden

  • lib/auth/pairing.ts — nieuw, secret/token-generatie en hash-helpers
  • lib/auth/pair-cookie.ts — nieuw, set/read/clear van s4m_pair-cookie
  • lib/session.tsSessionData uitbreiden met paired en pairedExpiresAt
  • app/(app)/layout.tsx — extra guard op vervallen paired-sessie
  • __tests__/lib/auth/pairing.test.ts — nieuw

Stappen

  1. lib/auth/pairing.ts:

    import { createHash, randomBytes, timingSafeEqual } from 'crypto'
    
    export function generateMobileSecret(): string {
      return randomBytes(32).toString('base64url')
    }
    
    export function generateDesktopToken(): string {
      return randomBytes(32).toString('base64url')
    }
    
    export function hashToken(token: string): string {
      return createHash('sha256').update(token).digest('hex')
    }
    
    export function verifyToken(token: string, hash: string): boolean {
      const a = Buffer.from(hashToken(token), 'hex')
      const b = Buffer.from(hash, 'hex')
      if (a.length !== b.length) return false
      return timingSafeEqual(a, b)
    }
    

    Twee aparte generators (niet één functie met arg) voorkomen dat dezelfde geheim per ongeluk twee keer wordt gebruikt.

  2. lib/auth/pair-cookie.ts:

    import { cookies } from 'next/headers'
    
    const COOKIE_NAME = 's4m_pair'
    const MAX_AGE = 120 // 2 min, gelijk aan pending-TTL van pairing
    
    export async function setPairCookie(desktopToken: string) {
      const jar = await cookies()
      jar.set(COOKIE_NAME, desktopToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        path: '/api/auth/pair',
        maxAge: MAX_AGE,
      })
    }
    
    export async function readPairCookie(): Promise<string | null> {
      const jar = await cookies()
      return jar.get(COOKIE_NAME)?.value ?? null
    }
    
    export async function clearPairCookie() {
      const jar = await cookies()
      jar.delete({ name: COOKIE_NAME, path: '/api/auth/pair' })
    }
    

    Path=/api/auth/pair zorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route.

  3. lib/session.tsSessionData interface:

    export interface SessionData {
      userId: string
      isDemo: boolean
      paired?: boolean          // true als sessie is aangemaakt via QR-pairing
      pairedExpiresAt?: number  // unix ms
    }
    

    Bestaande sessies blijven werken — beide velden zijn optioneel.

  4. app/(app)/layout.tsx — guard ná de bestaande if (!session.userId) redirect('/login'):

    if (session.paired && session.pairedExpiresAt && session.pairedExpiresAt < Date.now()) {
      session.destroy()
      redirect('/login?notice=paired-expired')
    }
    

    Hergebruikt het bestaande notice-querystring-patroon van M9's <NoticeToast /> voor de melding "Je sessie is verlopen, log opnieuw in".

  5. Tests__tests__/lib/auth/pairing.test.ts:

    • generateMobileSecret() produceert 43-karakter base64url (32 bytes)
    • hashToken is deterministisch
    • verifyToken is true voor geldig paar, false voor ongeldig
    • Twee verschillende generateMobileSecret()-calls geven verschillende waardes
    • Cookie helpers: HttpOnly bit gezet (via Next.js cookie-store mock)

Aandachtspunten

  • Geen middleware nodig voor de paired-expiry-check; layout-guard is voldoende. Middleware komt pas in beeld als proxy.ts herzien wordt (uit scope hier).
  • cookies().delete({ name, path }) moet dezelfde path specificeren als bij set, anders blijft de cookie staan.
  • crypto.randomBytes is sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.

Verificatie

  • npm run lint && npx tsc --noEmit && npm test && npm run build groen
  • Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
  • document.cookie op de pagina laat de cookie niet zien

ST-1003 — POST /api/auth/pair/start (anon, sets pre-auth cookie)

Bestanden

  • app/api/auth/pair/start/route.ts — nieuw
  • lib/rate-limit.ts — checken of bestaand (uit ST-608); anders helper toevoegen
  • __tests__/api/pair-start.test.ts — nieuw

Stappen

  1. Route Handler (vrij van authenticateApiRequest — dit is anon):

    import { NextRequest } from 'next/server'
    import { prisma } from '@/lib/prisma'
    import {
      generateMobileSecret, generateDesktopToken, hashToken,
    } from '@/lib/auth/pairing'
    import { setPairCookie } from '@/lib/auth/pair-cookie'
    import { rateLimit } from '@/lib/rate-limit'
    
    export const runtime = 'nodejs'
    
    const PENDING_TTL_MS = 2 * 60 * 1000
    
    export async function POST(request: NextRequest) {
      const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null
      const ua = request.headers.get('user-agent')?.slice(0, 255) ?? null
    
      // Rate-limit per IP — 10/min (zelfde patroon als ST-608)
      const limited = await rateLimit(`pair-start:${ip ?? 'anon'}`, 10, 60_000)
      if (limited) {
        return Response.json({ error: 'Te veel verzoeken' }, { status: 429 })
      }
    
      const mobileSecret = generateMobileSecret()
      const desktopToken = generateDesktopToken()
    
      const pairing = await prisma.loginPairing.create({
        data: {
          secret_hash: hashToken(mobileSecret),
          desktop_token_hash: hashToken(desktopToken),
          status: 'pending',
          desktop_ua: ua,
          desktop_ip: ip,
          expires_at: new Date(Date.now() + PENDING_TTL_MS),
        },
        select: { id: true, expires_at: true },
      })
    
      await setPairCookie(desktopToken)
    
      const origin = request.nextUrl.origin
      const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}`
    
      return Response.json({
        pairingId: pairing.id,
        mobileSecret,
        expiresAt: pairing.expires_at.toISOString(),
        qrUrl,
      })
    }
    
  2. Rate-limit helper — als lib/rate-limit.ts nog niet bestaat:

    • In-memory Map keyed op key, met sliding-window van timestamps; thread-safe genoeg voor v1 single-instance Vercel Functions.
    • Wordt ook door actions/auth.ts (login) gebruikt; verifieer met grep of die al bestaat — zo ja, hergebruik exact.
  3. Tests__tests__/api/pair-start.test.ts:

    • 200 response bevat pairingId, mobileSecret, qrUrl met fragment-syntax (#id=…&s=…)
    • Set-Cookie-header bevat s4m_pair=...; HttpOnly; SameSite=Lax; Path=/api/auth/pair; Max-Age=120
    • Database-rij heeft secret_hash, desktop_token_hash, geen plaintext
    • 11e call binnen 60s → 429
    • desktop_ua en desktop_ip worden opgeslagen bij aanwezigheid, anders null

Aandachtspunten

  • mobileSecret mag in de JSON-response — die is HTTPS-encrypted en wordt niet door browsers naar logs geschreven. Kritisch is dat het niet in request-URLs of server-toegangslogs belandt.
  • qrUrl gebruikt # (fragment), niet ?. Browsers strippen het fragment voordat ze de mobile pair-page ophalen — maar dat is uitvoerig getest in ST-1005 server-side: de page leest de URL niet, alleen de client component leest window.location.hash.
  • Geen idempotency-key nodig: een tweede start vanuit dezelfde tab maakt simpelweg een nieuwe pairing en cookie aan; oude cookie wordt overschreven.
  • Vercel-edge-of-fluid: runtime: 'nodejs' expliciet (niet 'edge') want we gebruiken crypto.randomBytes.

Verificatie

  • curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jar retourneert JSON + Set-Cookie
  • Body bevat qrUrl met #id=…&s=…
  • Rij in login_pairings heeft beide hashes ingevuld
  • Tests groen

Bestanden

  • app/api/auth/pair/stream/[pairingId]/route.ts — nieuw
  • __tests__/api/pair-stream.test.ts — nieuw (auth-test, geen full SSE-test)

Stappen

  1. Routebestand — exacte structuur uit app/api/realtime/solo/route.ts (incl. heartbeat, hard-close, abort-handler), maar:

    • Geen iron-session check; auth via s4m_pair-cookie:

      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 })
      }
      
    • Channel = 'scrum4me_pairing'

    • Filter shouldEmit: payload.pairing_id === pairingId

    • Auto-close ook bij payload status{'consumed', 'cancelled'} — niet alleen na 240 s

    • Geen sprint-resolve, geen userId-filter — eenvoudiger dan solo-route

  2. Initial-state-event vlak na verbinden: query de pairing-status één keer uit DB en stuur als event: state\ndata: {"status":"pending"} zodat de desktop niet hoeft te wachten op het eerste pg_notify (handig als pairing al approved is voordat de SSE opent — race-conditie).

  3. Tests:

    • GET zonder cookie → 401
    • GET met cookie maar onbekende pairingId → 404
    • GET met verlopen pairing → 410
    • GET met cookie die hasht naar een andere desktop_token_hash → 401

    Geen full-stream-test — vereist een Postgres-event-mock die het niet waard is voor v1. Manuele test dekt dit (zie verificatie).

Aandachtspunten

  • pairingId in het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.
  • EventSource op de client kan geen custom headers; cookie is daarom de enige praktische auth-methode. withCredentials: true op de client is verplicht.
  • Bij browser-tab-sluiten wordt request.signal.abort getriggered → cleanup zoals in solo-route.
  • Vermijd Prisma in de notification-handler; gebruik alleen pg.Client zoals solo-route. Status-check hierboven is de enige Prisma-call (vóór de stream start).

Verificatie

  • curl -N --cookie /tmp/jar http://localhost:3000/api/auth/pair/stream/<id> blijft open en print : heartbeat elke 25 s
  • Andere terminal: psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id='<id>'" → curl-uitvoer toont event binnen 1 s
  • Manuele 401-test: curl -N zonder cookie → JSON 401

ST-1005 — Server actions + mobiele bevestigingspagina

Bestanden

  • actions/pairing.ts — nieuw, drie Server Actions
  • app/(app)/m/pair/page.tsx — nieuw, Server Component
  • app/(app)/m/pair/pair-confirmation.tsx — nieuw, Client Component
  • __tests__/actions/pairing.test.ts — nieuw

Stappen

  1. actions/pairing.ts — volgt docs/patterns/server-action.md:

    'use server'
    
    import { revalidatePath } from 'next/cache'
    import { z } from 'zod'
    import { prisma } from '@/lib/prisma'
    import { getSession } from '@/lib/auth'
    import { verifyToken } from '@/lib/auth/pairing'
    
    const inputSchema = z.object({
      pairingId: z.string().cuid(),
      mobileSecret: z.string().min(40), // base64url van 32 bytes ≈ 43 chars
    })
    
    export async function getPairingForApproval(pairingId: string, mobileSecret: string) {
      const session = await getSession()
      if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
    
      const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
      if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
    
      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 { ok: false, error: 'Pairing niet gevonden' } as const
      if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
      if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
      if (!verifyToken(mobileSecret, pairing.secret_hash)) {
        return { ok: false, error: 'Ongeldig pairing-geheim' } as const
      }
    
      const me = await prisma.user.findUnique({
        where: { id: session.userId },
        select: { username: true },
      })
      return {
        ok: true,
        desktop_ua: pairing.desktop_ua,
        desktop_ip: pairing.desktop_ip,
        username: me?.username ?? '',
      } as const
    }
    
    export async function approvePairing(pairingId: string, mobileSecret: string) {
      const session = await getSession()
      if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
      if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } as const
    
      const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
      if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
    
      const pairing = await prisma.loginPairing.findUnique({
        where: { id: pairingId },
        select: { status: true, expires_at: true, secret_hash: true },
      })
      if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
      if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
      if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
      if (!verifyToken(mobileSecret, pairing.secret_hash)) {
        return { ok: false, error: 'Ongeldig pairing-geheim' } as const
      }
    
      const APPROVED_TTL_MS = 5 * 60 * 1000
      await prisma.loginPairing.update({
        where: { id: pairingId },
        data: {
          status: 'approved',
          user_id: session.userId,
          approved_at: new Date(),
          expires_at: new Date(Date.now() + APPROVED_TTL_MS),
        },
      })
      // Trigger emit pg_notify automatisch — geen revalidatePath nodig
      return { ok: true } as const
    }
    
    export async function cancelPairing(pairingId: string, mobileSecret: string) {
      // Vergelijkbaar met approvePairing maar status='cancelled', geen user_id; demo mag annuleren.
    }
    
  2. app/(app)/m/pair/page.tsx — Server Component, achter bestaande (app)/layout.tsx auth-guard:

    import { PairConfirmation } from './pair-confirmation'
    
    export default function PairPage() {
      return (
        <main className="container mx-auto max-w-md py-12">
          <h1 className="text-h2">Inloggen op desktop</h1>
          <p className="text-muted-foreground mt-2">
            Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont.
          </p>
          <PairConfirmation />
        </main>
      )
    }
    

    Geen searchParams! De page leest de URL überhaupt niet — alleen het client-island doet dat client-side via window.location.hash.

  3. app/(app)/m/pair/pair-confirmation.tsx — Client Component:

    'use client'
    
    import { useEffect, useState, useTransition } from 'react'
    import { Button } from '@/components/ui/button'
    import { toast } from 'sonner'
    import { getPairingForApproval, approvePairing, cancelPairing } from '@/actions/pairing'
    
    type State =
      | { kind: 'loading' }
      | { kind: 'invalid'; error: string }
      | { kind: 'ready'; pairingId: string; secret: string; ua: string | null; ip: string | null; username: string }
      | { kind: 'success' }
      | { kind: 'error'; error: string }
    
    function parseHash(): { id: string; s: string } | null {
      if (typeof window === 'undefined') return null
      const hash = window.location.hash.replace(/^#/, '')
      const params = new URLSearchParams(hash)
      const id = params.get('id'); const s = params.get('s')
      return id && s ? { id, s } : null
    }
    
    export function PairConfirmation() {
      const [state, setState] = useState<State>({ kind: 'loading' })
      const [pending, startTransition] = useTransition()
    
      useEffect(() => {
        const parsed = parseHash()
        if (!parsed) {
          setState({ kind: 'invalid', error: 'Ongeldige pairing-link' })
          return
        }
        getPairingForApproval(parsed.id, parsed.s).then((res) => {
          if (!res.ok) setState({ kind: 'invalid', error: res.error })
          else setState({
            kind: 'ready',
            pairingId: parsed.id, secret: parsed.s,
            ua: res.desktop_ua, ip: res.desktop_ip, username: res.username,
          })
        })
      }, [])
    
      function onApprove() {
        if (state.kind !== 'ready') return
        startTransition(async () => {
          const res = await approvePairing(state.pairingId, state.secret)
          if (!res.ok) { toast.error(res.error); return }
          // Wist secret uit URL zodat back/forward 'm niet onthult
          if (typeof window !== 'undefined') {
            window.history.replaceState(null, '', window.location.pathname)
          }
          setState({ kind: 'success' })
        })
      }
    
      function onCancel() {
        if (state.kind !== 'ready') return
        startTransition(async () => {
          await cancelPairing(state.pairingId, state.secret)
          setState({ kind: 'invalid', error: 'Pairing geannuleerd' })
        })
      }
    
      // Render-logica per state — kort:
      // loading → spinner
      // invalid → foutmelding + link "Terug naar dashboard"
      // ready → kaart met UA/IP/username + Bevestig/Annuleer
      // success → "Klaar — je kunt deze tab sluiten"
      // … (volledige JSX in implementation)
    }
    
  4. Tests__tests__/actions/pairing.test.ts:

    • getPairingForApproval met pending-pairing → ok: true + ua/ip/username
    • getPairingForApproval met al-approvedok: false
    • getPairingForApproval met verlopen → ok: false
    • getPairingForApproval met verkeerd secret → ok: false
    • approvePairing als demo-user → ok: false + DB onveranderd
    • approvePairing happy path → status approved, user_id gezet, expires_at bumped
    • cancelPairing happy path → status cancelled

Aandachtspunten

  • getPairingForApproval mág door demo-users worden aangeroepen (om iets te zien); alleen approvePairing blokkeert demo's.
  • pair-confirmation.tsx gebruikt geen useSearchParams — die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.
  • Na approve window.history.replaceState zonder de #hash zorgt dat browser-back de secret niet opnieuw onthult.
  • revalidatePath in approvePairing is niet nodig — de desktop hoort het via SSE, en de mobiele page heeft geen server-state om te ververversen.

Verificatie

  • npm run lint && npx tsc --noEmit && npm test && npm run build groen
  • Handmatig: log in op telefoon-emulator → bezoek /m/pair#id=…&s=… → kaart toont UA/IP → Bevestig → succes-state, URL is /m/pair zonder hash

ST-1006 — POST /api/auth/pair/claim (cookie-auth, schrijft iron-session)

Bestanden

  • app/api/auth/pair/claim/route.ts — nieuw
  • __tests__/api/pair-claim.test.ts — nieuw

Stappen

  1. Route Handler:

    import { NextRequest } from 'next/server'
    import { getIronSession } from 'iron-session'
    import { cookies } from 'next/headers'
    import { prisma } from '@/lib/prisma'
    import { SessionData, sessionOptions } from '@/lib/session'
    import { hashToken } from '@/lib/auth/pairing'
    import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie'
    
    export const runtime = 'nodejs'
    
    const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur
    
    export async function POST(request: NextRequest) {
      const desktopToken = await readPairCookie()
      if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
    
      const body = await request.json().catch(() => null) as { pairingId?: string } | null
      const pairingId = body?.pairingId
      if (!pairingId) return Response.json({ error: 'pairingId vereist' }, { status: 400 })
    
      const desktopTokenHash = hashToken(desktopToken)
    
      // Atomic: WHERE status='approved' AND token-hash + expiry → consumed, RETURNING user_id
      const updated = await prisma.loginPairing.updateMany({
        where: {
          id: pairingId,
          status: 'approved',
          desktop_token_hash: desktopTokenHash,
          expires_at: { gt: new Date() },
        },
        data: { status: 'consumed', consumed_at: new Date() },
      })
      if (updated.count !== 1) {
        // Was het wél een geldige cookie maar al consumed? → 410. Anders → 401.
        const exists = await prisma.loginPairing.findFirst({
          where: { id: pairingId, desktop_token_hash: desktopTokenHash },
          select: { status: true, expires_at: true },
        })
        await clearPairCookie()
        if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 })
        if (exists.status === 'consumed') return Response.json({ error: 'Al gebruikt' }, { status: 410 })
        return Response.json({ error: 'Niet beschikbaar' }, { status: 410 })
      }
    
      const pairing = await prisma.loginPairing.findUnique({
        where: { id: pairingId },
        select: { user_id: true, user: { select: { is_demo: true } } },
      })
      if (!pairing?.user_id) {
        await clearPairCookie()
        return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
      }
    
      const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
      session.userId = pairing.user_id
      session.isDemo = pairing.user?.is_demo ?? false
      session.paired = true
      session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
      await session.save()
    
      await clearPairCookie()
      return Response.json({ ok: true })
    }
    
  2. Tests:

    • 200 + iron-session cookie + clear s4m_pair na succes
    • 410 op tweede claim met dezelfde cookie
    • 401 zonder cookie
    • 401 met cookie die hasht naar andere pairing
    • paired-sessie bevat paired: true en pairedExpiresAt rond now + 8h

Aandachtspunten

  • updateMany gebruiken (niet update) want we hebben een composite WHERE met status + desktop_token_hash + expires_at; update kan alleen op unique keys.
  • Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in count = 1 voor één caller en count = 0 voor de ander.
  • clearPairCookie ook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch — Max-Age=120 regelt het ook).
  • De session.isDemo check overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maar is_demo doorzetten is een extra vangnet.

Verificatie

  • Handmatig: na approve in mobiele tab, POST naar /api/auth/pair/claim met de cookie van start → 200 + Set-Cookie: session=...
  • curl -X POST zonder cookie → 401
  • Tweede claim → 410

ST-1007 — Desktop UI: QR-render + SSE-listener op /login

Bestanden

  • app/login/page.tsx — bestaand, knop "Inloggen via mobiel" toevoegen
  • app/login/qr-login-button.tsx — nieuw, Client Component
  • package.jsonqrcode.react toevoegen
  • __tests__/components/qr-login-button.test.tsx — minimale render-test

Stappen

  1. Dependency: npm install qrcode.react — direct in dependencies per CLAUDE.md-conventie.

  2. qr-login-button.tsx:

    'use client'
    
    import { useEffect, useRef, useState } from 'react'
    import { useRouter } from 'next/navigation'
    import { QRCodeSVG } from 'qrcode.react'
    import { Button } from '@/components/ui/button'
    import { toast } from 'sonner'
    
    type Phase =
      | { kind: 'idle' }
      | { kind: 'starting' }
      | { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number }
      | { kind: 'expired'; pairingId: string }
      | { kind: 'claiming' }
    
    export function QrLoginButton() {
      const router = useRouter()
      const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
      const sseRef = useRef<EventSource | null>(null)
    
      async function start() {
        setPhase({ kind: 'starting' })
        const res = await fetch('/api/auth/pair/start', {
          method: 'POST', credentials: 'same-origin',
        })
        if (!res.ok) { toast.error('Kon QR-code niet aanmaken'); setPhase({ kind: 'idle' }); return }
        const data = await res.json() as { pairingId: string; qrUrl: string; expiresAt: string }
        setPhase({
          kind: 'showing',
          pairingId: data.pairingId,
          qrUrl: data.qrUrl,
          expiresAt: new Date(data.expiresAt).getTime(),
        })
      }
    
      // SSE-koppeling
      useEffect(() => {
        if (phase.kind !== 'showing') return
        const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, {
          withCredentials: true,
        })
        sseRef.current = es
    
        es.addEventListener('message', async (ev) => {
          const data = JSON.parse(ev.data) as { status?: string }
          if (data.status === 'approved') {
            es.close()
            setPhase({ kind: 'claiming' })
            const res = await fetch('/api/auth/pair/claim', {
              method: 'POST',
              credentials: 'same-origin',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ pairingId: phase.pairingId }),
            })
            if (!res.ok) {
              toast.error('Inloggen mislukt')
              setPhase({ kind: 'idle' }); return
            }
            router.push('/dashboard')
          }
        })
        es.addEventListener('error', () => { /* silent — laat reconnecten */ })
    
        return () => { es.close() }
      }, [phase, router])
    
      // Aftellende timer + auto-expire
      useEffect(() => {
        if (phase.kind !== 'showing') return
        const t = setInterval(() => {
          if (Date.now() > phase.expiresAt) {
            sseRef.current?.close()
            setPhase({ kind: 'expired', pairingId: phase.pairingId })
            clearInterval(t)
          }
        }, 1000)
        return () => clearInterval(t)
      }, [phase])
    
      // Render: knop / QR + countdown / "Vernieuwen" — JSX hier weggelaten voor brevity
    }
    
  3. app/login/page.tsx — knop toevoegen onder of naast het wachtwoord-formulier:

    <div className="my-4 flex items-center gap-2">
      <div className="border-border h-px flex-1 border-t" />
      <span className="text-muted-foreground text-sm">of</span>
      <div className="border-border h-px flex-1 border-t" />
    </div>
    <QrLoginButton />
    

    MD3-tokens uit docs/design/styling.md; geen willekeurige Tailwind-kleuren.

  4. A11y: QR-component krijgt aria-label="QR-code voor mobiel inloggen" en de URL wordt visueel als kopieer-bare tekst onder de QR getoond zodat screenreaders en gebruikers met cameraproblemen de URL handmatig kunnen openen.

Aandachtspunten

  • EventSource({ withCredentials: true }) is verplicht zodat de browser de s4m_pair-cookie meestuurt; standaard verstuurt EventSource geen credentials.
  • Cleanup-volgorde: es.close() eerst, dán fetch claim. Anders kan een tweede approved-event tussen close-en-claim binnenkomen en een dubbele claim triggeren (server vangt het op met 410, maar netter is het netjes te sluiten).
  • qrcode.react exporteert zowel QRCodeSVG als QRCodeCanvas. Kies SVG: schaalbaarder voor printen/screenshots en kleinere bundle.
  • Geen next/image — QR is dynamisch en client-side.

Verificatie

  • npm run lint && npx tsc --noEmit && npm test && npm run build groen
  • Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar /dashboard met session.paired === true
  • DevTools Network-tab: geen URL bevat s= (alleen Set-Cookie/Cookie headers)
  • Speel met een verlopen QR: na 2 min toont knop "Vernieuwen", Vernieuwen start nieuwe pairing

ST-1008 — Documentatie + acceptatietest

Bestanden

  • docs/api/rest-contract.md — drie nieuwe endpoints
  • docs/architecture.md — sectie "QR-pairing flow" + threat-model
  • docs/patterns/qr-login.md — nieuw pattern-doc
  • CLAUDE.md — verwijzing naar het pattern-doc in de patterns-tabel
  • __tests__/integration/qr-pairing-e2e.test.ts — optioneel, alleen als de test-infra het toelaat

Stappen

  1. docs/api/rest-contract.md — drie endpoints documenteren met request/response, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief --cookie-jar. Voeg een sectie "Cookie-mechaniek" toe die uitlegt dat s4m_pair een tijdelijke pre-auth cookie is, anders dan de iron-session cookie.

  2. docs/architecture.md — sectie "QR-pairing flow" met:

    • Sequence-diagram (mermaid of ASCII analoog aan M8)
    • Threat-model:
      • Replay: atomic updateMany met status='approved' voorkomt dubbele claim
      • Phishing-QR: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
      • Demo-block: approvePairing early return op session.isDemo
      • Rate-limit: 10 starts per IP per minuut
      • Secret-hashing: alleen sha256-hashes in DB; secrets verlaten desktop alleen via QR-fragment + POST-body
      • TTL-rationale: 2 min pending vs. 5 min approved vs. 8 u paired-sessie — verschillen verklaren
      • Subsectie "Waarom geen secret in URL": fragment-eigenschap (browsers sturen #… niet naar server); HttpOnly cookie voor desktop-bewijs; geen secret in access logs / reverse-proxy logs / observability / browsergeschiedenis
  3. docs/patterns/qr-login.md — herbruikbaar patroon: "unauth-SSE-via-pre-auth-cookie". Toekomstige features die een pre-auth flow met realtime-updates willen kunnen dit kopiëren (bv. "ontvang webhook live", "long-running export"). Inclusief:

    • Wanneer dit patroon te gebruiken (er moet realtime-feedback zijn vóór de gebruiker is geauthenticeerd)
    • Verwijzingen naar lib/auth/pair-cookie.ts als sjabloon
    • Risico's en mitigaties
  4. CLAUDE.md — in de Implementatiepatronen-tabel een rij | QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |.

  5. Acceptatie-scenario's (handmatig, eventueel automatiseerbaar in v2):

    1. Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
    2. Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
    3. Replay — claim de pairing twee keer → tweede call 410
    4. Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
    5. Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
    6. Ontbrekende cookie op SSE/claim — verwijder s4m_pair in DevTools, herhaal → 401
    7. Secret niet in access logs — controleer Vercel runtime-logs (via mcp__a1fa0fcf-…__get_runtime_logs) en lokale dev-logs; zoek op de secret-string en op s=-substrings; verwacht: 0 hits

Aandachtspunten

  • Zorg dat de runtime-logs MCP-controle in docs/qa/api-test-plan.md belandt zodat hij bij elke release herhaalbaar is.
  • docs/patterns/qr-login.md mag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.

Verificatie

  • npm run lint && npx tsc --noEmit && npm test && npm run build groen
  • Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie
  • vendor/scrum4me-submodule in mcp gesynced ná schema-merge

Branch- en commit-strategie

Per Branch & PR Strategy: één branch voor de hele milestone, PR pas na handmatige acceptatie door de gebruiker. Reden: elke push triggert een Vercel preview-build, en op het Hobby-account zijn die schaars.

Branch: feat/M10-qr-login — afgesplitst van main na merge van de planning-PR (#11). Alle ST-1001..ST-1008-werk landt op deze branch.

Commits in chronologische volgorde, één per stap, ST-code in de titel. Voorbeeld-progressie:

feat(ST-1001): add LoginPairing model
feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel
feat(ST-1002): add pairing helpers and pre-auth cookie
feat(ST-1002): extend SessionData with paired flag
feat(ST-1002): guard expired paired sessions in app layout
feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie
feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
feat(ST-1005): add pairing server actions
feat(ST-1005): add mobile pair confirmation page with hash-fragment client island
feat(ST-1006): add /api/auth/pair/claim with atomic consume
chore(ST-1007): add qrcode.react dependency
feat(ST-1007): add QR login button on /login with SSE listener
docs(ST-1008): document QR-pairing endpoints in api.md
docs(ST-1008): add QR-pairing flow and threat-model to architecture
docs(ST-1008): add qr-login pattern doc

Push + PR: pas nadat ST-1008-acceptatie-scenario 1 (happy path, end-to-end op localhost) handmatig groen is bevonden door de gebruiker. Tussentijdse "klaar voor jouw test"-momenten markeren we lokaal — niet met een push.

Pre-merge gates (uit CLAUDE.md DoD):

  • npm run lint && npm test && npm run build groen op CI
  • Schema-wijziging in ST-1001 → wekelijkse drift-check trig_015FFUnxjz9WMuhhWNGBQKFD mag niet rood staan; vendor/scrum4me-submodule in mcp meebewegen na merge

Wanneer dit aanpassen: zodra het Vercel-account naar Pro gaat — zie CLAUDE.md.


Reseed-stap (eenmalig vóór ST-1001-implementatie)

De backlog-markdown bevat ST-1001..1008, maar de live database heeft die rijen nog niet. Voordat mcp__scrum4me__get_claude_context ze als next-story kan teruggeven:

npx prisma db seed

Verifieer dat M10 en zijn 8 stories als status=OPEN in de DB staan. Daarna geeft mcp__scrum4me__implement_next_story product_id=cmohfotr70000jwrt0hw4q020 automatisch ST-1001 als startpunt.

Let op: seed kan bestaande dev-data overschrijven. Doe dit op een dev-DB, niet productie. Voor productie volstaat het om de stories handmatig of via een eenmalige migratie-script in te voegen — buiten scope hier.