---
title: "Authentication, Sessions & Demo Policy"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
related: [qr-pairing.md](./qr-pairing.md)
---

## Authenticatieflow

```
Registratie:
  POST /register → valideer username/wachtwoord → bcrypt hash → opslaan in DB
  → iron-session cookie zetten → redirect /dashboard

Inloggen:
  POST /login → gebruiker ophalen op username → bcrypt vergelijken
  → bij match: iron-session cookie zetten → redirect /dashboard
  → bij mismatch: generieke foutmelding (geen onderscheid)

Sessie per request:
  proxy.ts → sessiecookie-aanwezigheid controleren
  → beschermde routes: redirect /login als geen sessiecookie aanwezig is
  → app layout valideert de volledige sessie server-side

API-aanroepen (Claude Code):
  Authorization: Bearer <token> header → SHA-256 hash → opzoeken in api_tokens
  → revoked_at null check → user_id ophalen → is_demo check voor schrijfrechten

Uitloggen:
  Server Action → iron-session vernietigen → redirect /login
```

---

## Demo-user policy (ST-1110)

Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags:

### Laag 1 — Middleware-guard (proxy.ts)

`proxy.ts` blokkeert alle non-GET requests op `/api/*` voor demo-gebruikers voordat de route handler draait (defense in depth). Implementatie gebruikt `unsealData` direct (geen `getIronSession`) omdat `request.cookies` in middleware `RequestCookies` is, niet de volledige `CookieStore`.

```ts
// Whitelist: paden die demo mag aanroepen ondanks non-GET
const DEMO_WRITE_ALLOWLIST = [
  '/api/cron/', // machine-auth, irrelevant voor demo
]
// pair/start en pair/claim staan NIET in de allowlist — zie Laag 2
```

### Laag 2 — Per-route guards (Server Actions & Route Handlers)

Elke schrijfactie controleert `session.isDemo` vóór DB-toegang:

```ts
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
```

**QR-pairing (M10):**
- `pair/start`: isDemo-check via `getIronSession(await cookies(), sessionOptions)` — blokkeert demo-desktops
- `pair/claim`: check `pairing.user?.is_demo` na DB-read — blokkeert demo-users die op mobiel hebben goedgekeurd
- `pair/approve` en `pair/cancel`: waren al geblokkeerd vóór ST-1110

**Realtime SSE en cron-routes:** niet relevant voor demo-bescherming (SSE is read-only, cron gebruikt Bearer-auth).

### Laag 3 — UI-laag (DemoTooltip)

Alle write-knoppen zijn `disabled` met een `DemoTooltip show={isDemo}` wrapper zodat demo-bezoekers de app-mogelijkheden kunnen zien. Consistente component: `components/shared/demo-tooltip.tsx`.

Patroon:
```tsx
<DemoTooltip show={isDemo}>
  <Button disabled={isDemo} onClick={() => !isDemo && handleAction()}>
    Actie
  </Button>
</DemoTooltip>
```

**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && <span {...listeners} />}`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren.

---

## Claude job queue (M13 — ST-1111)

Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status.

### State machine

```
QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE
                                               → FAILED
       → CANCELLED (door user)
CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist)
QUEUED  → CLAIMED (re-claim na stale reset; snapshot refreshed)
```

**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd.

### ClaudeJob model

```
claude_jobs
  id, user_id, product_id, task_id
  status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED)
  claimed_by_token_id (FK → api_tokens, nullable)
  claimed_at, started_at, finished_at
  plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim
  branch, pushed_at, summary, error
  verify_result: VerifyResult? (ALIGNED|PARTIAL|EMPTY|DIVERGENT)
  @@index([user_id, status])
  @@index([task_id, status])
  @@index([status, claimed_at])   — voor stale-claim cleanup
```

**VerifyResult enum** — vergelijking van de git-diff in de worktree versus `plan_snapshot`:

| Waarde | Betekenis |
|---|---|
| `ALIGNED` | Diff dekt het plan volledig — implementatie klopt met de intentie |
| `PARTIAL` | Diff dekt slechts een deel van het plan — waarschuwing, maar geen blocker |
| `EMPTY` | Geen codewijzigingen in de diff — blocker, tenzij de task `verify_only=true` heeft |
| `DIVERGENT` | Diff bevat significant meer dan het plan — review extra zorgvuldig |

**`verify_only` op Task** — wanneer `true` mag de agent de task als DONE markeren ook als de diff leeg is. Bedoeld voor taken die expliciet om verificatie (niet implementatie) vragen.

**`pushed_at`** — timestamp waarop de agent de feature-branch naar origin heeft gepusht. Aanwezig zodra de push slaagde; absent als er geen wijzigingen waren of de push mislukte.

### NOTIFY/LISTEN flow

```
UI klikt 'Voer uit'
  → enqueueClaudeJobAction()       Server Action
  → prisma.claudeJob.create(QUEUED)
  → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...})
  → /api/realtime/solo SSE         server-side filter: user_id + product_id
  → EventSource.onmessage          browser: handleJobEvent()
  → useSoloStore.claudeJobsByTaskId map
  → SoloTaskCard pill + dialog-footer update
```

### Idempotency

`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken.

### Auto-promote task-status op job-overgangen

Twee Postgres-triggers houden `task.status` in sync met `claude_job.status` zodat de Solo-kaart altijd in de juiste kolom staat:

- **`claude_job_claim_to_task`** (`prisma/migrations/20260501130000_promote_task_to_in_progress_on_claim`): bij INSERT met status `CLAIMED|RUNNING` of UPDATE OF status naar `CLAIMED|RUNNING`, promoot de bijbehorende task van `TO_DO` naar `IN_PROGRESS`. Forceert niet vanuit andere status — handmatige overrides (REVIEW, DONE) blijven staan.
- **`claude_job_status_to_task`** (`prisma/migrations/20260501110000_sync_task_status_from_claude_job`): bij DONE zet de task ook op `DONE`. Idempotent: skip wanneer task al DONE is.

De bestaande `notify_task_change`-trigger op `tasks` vuurt automatisch de pg_notify naar `/api/realtime/solo` zodat de UI direct synct — geen extra plumbing in de SSE-handler nodig.

### Hybride-ready

De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd.

## Environment variables

| Variabele | Doel | Waar te vinden |
|---|---|---|
| `DATABASE_URL` | Prisma database-verbinding | Neon dashboard → Connection string (pooled) |
| `DIRECT_URL` | Directe verbinding voor migraties én voor de LISTEN/NOTIFY-verbinding van het Solo Paneel realtime-endpoint | Neon dashboard → Connection string (unpooled) |
| `SESSION_SECRET` | Versleutelingssleutel voor iron-session | Genereer met `openssl rand -base64 32` |
| `NODE_ENV` | Omgevingsmodus | Automatisch gezet door Vercel / Node |

`.env.example`:
```bash
# Database
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"

# Sessie
SESSION_SECRET="vervang-dit-met-openssl-rand-base64-32-output"

# Optioneel
NODE_ENV="development"
```

---

## Deployment

**Hosting:** Vercel (Hobby — gratis voor v1)
**CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main`
**Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap
**Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema
**Prisma generatie:** `prisma generate` (single client generator)
**Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document

### Deployment checklist (pre-launch)

- [ ] `DATABASE_URL` en `DIRECT_URL` gezet in Vercel dashboard (Neon connection strings)
- [ ] `SESSION_SECRET` gezet in Vercel dashboard (min. 32 tekens)
- [ ] `prisma migrate deploy` uitgevoerd op productiedatabase
- [ ] Demo-gebruiker aangemaakt via seed of handmatig
- [ ] API-token aangemaakt en getest met `curl`-aanroep naar `/api/products`
- [ ] Vercel Analytics actief in het Vercel dashboard na eerste productiebezoek
- [ ] Vercel preview-deployments getest op een PR
- [ ] `next build` lokaal geslaagd zonder TypeScript-fouten

---

## Kostenscattting

| Service | Plan | Maandelijkse kosten |
|---|---|---|
| Vercel | Hobby | Gratis |
| Neon | Free tier (0.5 GB, 190 compute-uren) | Gratis |
| GitHub | Free | Gratis |
| Domein | Eigen domein (optioneel) | ~€1–2/maand |
| **Totaal** | | **€0–2/maand** |

> Bij groei naar meerdere gebruikers (v2): Neon Launch plan (~$19/maand) en Vercel Pro (~$20/maand) zijn de eerste stappen omhoog.
