* 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>
894 lines
39 KiB
Markdown
894 lines
39 KiB
Markdown
---
|
||
title: "M10 — Password-loze inlog via QR-pairing"
|
||
status: active
|
||
audience: [maintainer, contributor]
|
||
language: nl
|
||
last_updated: 2026-05-03
|
||
applies_to: [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 1–2 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](../backlog/index.md#m10-password-loze-inlog-via-qr-pairing).
|
||
Functional spec: zie [functional.md § F-01b](../specs/functional.md#f-01b-inloggen-via-mobiel-qr-pairing).
|
||
|
||
**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**:
|
||
|
||
```prisma
|
||
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`):
|
||
|
||
```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`
|
||
|
||
---
|
||
|
||
## ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
|
||
|
||
**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.ts` — `SessionData` 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`**:
|
||
|
||
```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`**:
|
||
|
||
```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.ts`** — `SessionData` interface:
|
||
|
||
```ts
|
||
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')`:
|
||
|
||
```ts
|
||
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):
|
||
|
||
```ts
|
||
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
|
||
|
||
---
|
||
|
||
## ST-1004 — SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth)
|
||
|
||
**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:
|
||
|
||
```ts
|
||
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`:
|
||
|
||
```ts
|
||
'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:
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```tsx
|
||
'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-`approved` → `ok: 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:
|
||
|
||
```ts
|
||
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.json` — `qrcode.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`**:
|
||
|
||
```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:
|
||
|
||
```tsx
|
||
<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](../runbooks/branch-and-commit.md): **éé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.
|