diff --git a/next.config.ts b/next.config.ts index ab580d4..fe81c08 100644 --- a/next.config.ts +++ b/next.config.ts @@ -67,6 +67,15 @@ const nextConfig: NextConfig = { // Later entries override same-named headers of earlier matches (Next.js header semantics). return [ { source: "/:path*", headers: securityHeaders }, + { + // Browsers must revalidate the service worker on every navigation to pick up updates. + source: "/sw.js", + headers: [ + { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + { key: "Service-Worker-Allowed", value: "/" }, + { key: "Content-Type", value: "application/javascript; charset=utf-8" }, + ], + }, ...EMBEDDABLE_FILE_ROUTES.map((source) => ({ source, headers: [ diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts new file mode 100644 index 0000000..86d3c33 --- /dev/null +++ b/scripts/smoke-auth.ts @@ -0,0 +1,94 @@ +/** + * Authenticated HTTP smoke against a running dev server — WITHOUT typing passwords: + * builds the session user via finalizeIdentityLogin (same code path as a real login after + * password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET. + * + * Usage (from repo root, dev server running): + * BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts + */ +import "dotenv/config"; +import { encode } from "next-auth/jwt"; +import { prisma } from "../src/server/db"; +import { finalizeIdentityLogin } from "../src/server/auth"; + +const BASE = process.env.BASE ?? "http://localhost:3099"; +const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; + +type Check = { path: string; expect?: number[]; mustContain?: string }; + +const PLAN: Record = { + "backoffice@demo.example": [ + { path: "/dashboard", expect: [200] }, + { path: "/work-orders", expect: [200] }, + { path: "/imports", expect: [200] }, + { path: "/customers", expect: [200] }, + { path: "/sites", expect: [200] }, + { path: "/teams", expect: [200] }, + { path: "/reports", expect: [200] }, + { path: "/documents", expect: [200] }, + { path: "/notifications", expect: [200] }, + { path: "/search?q=a", expect: [200] }, + { path: "/work-orders/emergency-review", expect: [200] }, + { path: "/settings/order-types", expect: [200] }, + { path: "/settings/audit", expect: [200, 403, 307] }, + ], + "monteur@demo.example": [ + { path: "/", expect: [307, 308] }, + { path: "/m", expect: [200] }, + { path: "/m/orders", expect: [200] }, + { path: "/m/emergency", expect: [200] }, + { path: "/m/sync", expect: [200] }, + { path: "/m/profile", expect: [200] }, + ], +}; + +async function cookieFor(email: string): Promise { + const identity = await prisma.identity.findUnique({ where: { email } }); + if (!identity) throw new Error(`identity ${email} not found (seed?)`); + const user = await finalizeIdentityLogin(identity.id, "demo"); + if (!user) throw new Error(`no active membership for ${email}`); + const token = { + sub: user.id, + name: user.name, + email: user.email, + userId: user.id, + identityId: user.identityId, + tenantId: user.tenantId, + tenantSlug: user.tenantSlug, + activeMembershipId: user.activeMembershipId, + memberships: user.memberships, + roles: user.roles, + permissions: user.permissions, + isPlatformAdmin: user.isPlatformAdmin, + mfaEnrolled: user.mfaEnrolled, + }; + const value = await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 30 }); + return `${COOKIE}=${value}`; +} + +async function main() { + let failures = 0; + for (const [email, checks] of Object.entries(PLAN)) { + const cookie = await cookieFor(email); + console.log(`\n== ${email}`); + for (const c of checks) { + const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" }); + const body = res.status === 200 ? await res.text() : ""; + const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body); + const okStatus = !c.expect || c.expect.includes(res.status); + const okText = !c.mustContain || body.includes(c.mustContain); + const ok = okStatus && okText && !errorPage; + if (!ok) failures++; + const loc = res.headers.get("location"); + console.log(`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${errorPage ? " [error page]" : ""}`); + } + } + await prisma.$disconnect(); + console.log(failures ? `\n${failures} Fehler` : "\nOK"); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 5e1aa8e..4fbe6f6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -61,12 +61,14 @@ export function proxy(request: NextRequest) { export const config = { // Everything except static assets. // + // `sw.js` (PWA Service Worker): muss auch mit abgelaufener Sitzung für die Update-Prüfung + // erreichbar sein; enthält keine Mandantendaten. // Ergänzt um `webmanifest`: das PWA-Manifest muss auch ohne Session ausgeliefert // werden, sonst bekommt der Browser für /site.webmanifest die Login-Seite statt // JSON. Die Logo-/Favicon-Dateien sind über die Endungen bereits abgedeckt. // Bewusst KEINE Verzeichnis-Ausnahme für `assets/` — das ist zugleich die // App-Route des Asset-Inventars und muss hinter dem Gate bleiben. matcher: [ - "/((?!_next/static|_next/image|favicon.ico|site.webmanifest|.*\\.(?:svg|png|jpg|ico|webmanifest)).*)", + "/((?!_next/static|_next/image|favicon.ico|site.webmanifest|sw\\.js|.*\\.(?:svg|png|jpg|ico|webmanifest)).*)", ], };