M12 / ST-1110: Demo gebruiker read-only (#17)
* feat(ST-1110.3): add proxy.ts demo-guard for non-GET API routes
* feat(ST-1110.3+4): demo-guard proxy + block demo in QR-pairing
- proxy.ts: gebruik unsealData ipv getIronSession (middleware-compatibel)
- pair/start: isDemo-check via cookies() guard
- pair/claim: check pairing.user.is_demo na DB-read; 403 + clearPairCookie
* feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip
Convert all !isDemo && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
* test(ST-1110.6): proxy demo-guard coverage — 403 for demo+non-GET on /api/*
* docs(ST-1110.7): document three-layer demo-readonly policy and mirror plan
This commit is contained in:
parent
8a9fb9d32b
commit
1cb5772edd
19 changed files with 413 additions and 142 deletions
40
proxy.ts
40
proxy.ts
|
|
@ -1,17 +1,43 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { sessionOptions } from '@/lib/session'
|
||||
import { unsealData } from 'iron-session'
|
||||
import { sessionOptions, type SessionData } from '@/lib/session'
|
||||
|
||||
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
|
||||
const authRoutes = ['/login', '/register']
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname
|
||||
const isProtected = protectedRoutes.some(r => path.startsWith(r))
|
||||
const isAuthRoute = authRoutes.some(r => path.startsWith(r))
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
// Check cookie existence only — full session validation happens in layout.tsx
|
||||
// Paden die demo MAY aanroepen ook al zijn het non-GET — worden ingevuld na ST-1110.4
|
||||
const DEMO_WRITE_ALLOWLIST = [
|
||||
'/api/cron/', // machine-auth, irrelevant for demo
|
||||
]
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname, method } = { pathname: request.nextUrl.pathname, method: request.method }
|
||||
|
||||
// Demo-guard: block non-GET API writes for demo users (defense in depth)
|
||||
if (
|
||||
pathname.startsWith('/api/') &&
|
||||
!SAFE_METHODS.has(method) &&
|
||||
!DEMO_WRITE_ALLOWLIST.some(p => pathname.startsWith(p))
|
||||
) {
|
||||
const raw = request.cookies.get(sessionOptions.cookieName)?.value
|
||||
if (raw) {
|
||||
const session = await unsealData<SessionData>(raw, { password: sessionOptions.password as string })
|
||||
if (session.isDemo) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Niet beschikbaar in demo-modus' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route protection: check cookie existence only — full validation in layout.tsx
|
||||
const hasSession = !!request.cookies.get(sessionOptions.cookieName)?.value
|
||||
const isProtected = protectedRoutes.some(r => pathname.startsWith(r))
|
||||
const isAuthRoute = authRoutes.some(r => pathname.startsWith(r))
|
||||
|
||||
if (isProtected && !hasSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
|
|
@ -25,5 +51,5 @@ export function proxy(request: NextRequest) {
|
|||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue