Bundles the user-prioritised follow-ups from the post-audit punch-list.
Batch A — pipeline + EOI safety:
- §1.1 timeline buildAuditDescription renders diff fields ("leadCategory → hot_lead").
- §4.13 EOI rejection cascade: notification to assigned rep + audit row + rose banner.
- §4.10b finish doc-detail: SigningProgress reuse, linked-entity names (server-resolved),
per-event icons + tooltips + show-more in activity panel.
- §7.2 stage guidance card replaces empty Payments slot pre-reservation.
- §4.15 deal-pulse trigger audit (docs/deal-pulse-trigger-audit.md).
Batch B — UX consistency + docs:
- §1.4 quick log-contact button on interest header.
- §2.1 contact-log compose: Dialog → Sheet.
- §7.1 docs/deal-pulse explainer page; /docs/ in PUBLIC_PATHS.
- DocumentStatus now includes 'rejected' + 'declined' across constants, labels, tone maps.
Audit-side residuals:
- M-NEW-1 /me/ports skips port-context requirement.
- M-AU03 audit log CSV export endpoint + UI button.
- M-IN03 dead receipt-scanner.ts deleted; live path already per-port.
- M-P01 pg_trgm GIN indexes (migration 0071).
- §10.1 webhook tests verified passing (was stale).
Deferred per user direction:
- §11.3 email copy refactor (needs old-CRM reference).
- M-EM03 IMAP bounce-to-interest linking.
Tests: 1374/1374. tsc + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
9.6 KiB
TypeScript
240 lines
9.6 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
/**
|
|
* Per-request CSP nonce — drops `'unsafe-inline'` from script-src in
|
|
* prod by giving every inline script a unique nonce that Next reads
|
|
* from the `content-security-policy` REQUEST header and threads through
|
|
* its RSC bootstrap + Server Actions. build-auditor H1.
|
|
*
|
|
* Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next HMR injects
|
|
* runtime-evaluated scripts the nonce mechanism doesn't reach.
|
|
* style-src stays at `'unsafe-inline'` because Tailwind/Radix runtime
|
|
* style injection has no nonce story yet (revisit when Tailwind v5
|
|
* ships a nonce-able API).
|
|
*/
|
|
function buildCspWithNonce(nonce: string, isProd: boolean): string {
|
|
const scriptSrc = isProd
|
|
? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
|
|
: "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://unpkg.com https://unpkg.com";
|
|
|
|
const connectSrc = isProd
|
|
? "connect-src 'self' ws: wss: https:"
|
|
: "connect-src 'self' ws: wss: https: http://unpkg.com https://unpkg.com";
|
|
|
|
return [
|
|
"default-src 'self'",
|
|
scriptSrc,
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob: https:",
|
|
"font-src 'self' data:",
|
|
connectSrc,
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"object-src 'none'",
|
|
].join('; ');
|
|
}
|
|
|
|
function generateNonce(): string {
|
|
// crypto.randomUUID is collision-free across requests; base64-encode
|
|
// to match the CSP spec shape (no `-`/`_`-only restrictions for
|
|
// CSP3 nonce values, but base64 stays safe across HTTP header
|
|
// serialization).
|
|
return Buffer.from(crypto.randomUUID()).toString('base64');
|
|
}
|
|
|
|
/**
|
|
* Paths that do not require an authenticated session.
|
|
* Checked with startsWith, so /auth/ covers /auth/callback etc.
|
|
*/
|
|
const PUBLIC_PATHS: string[] = [
|
|
'/login',
|
|
'/reset-password',
|
|
'/set-password',
|
|
'/auth/',
|
|
'/api/auth/',
|
|
'/api/public/',
|
|
'/api/health',
|
|
'/api/webhooks/',
|
|
// First-run / cold-start: the unauthenticated /setup and /login pages
|
|
// call /api/v1/bootstrap/status to decide whether to render the setup
|
|
// form. The route handlers self-protect via hasAnySuperAdmin().
|
|
'/setup',
|
|
'/api/v1/bootstrap/',
|
|
'/scan',
|
|
// §7.1: public sales-playbook docs (deal pulse, etc) so the "Full
|
|
// guide" link inside the in-app popover is reachable without a
|
|
// session — and shareable to external collaborators.
|
|
'/docs/',
|
|
// M-R01: portal allowlist narrowed from blanket `/portal/` to the
|
|
// unauthenticated entry-point routes only. Other `/portal/*` paths
|
|
// now flow through the middleware backstop below which redirects to
|
|
// `/portal/login` when the portal_session cookie is missing. Closes
|
|
// the silent-bypass class where a new portal route landed without
|
|
// its own session check.
|
|
'/portal/login',
|
|
'/portal/activate',
|
|
'/portal/reset-password',
|
|
// Portal API endpoints handle their own session checks (better-auth).
|
|
'/api/portal/',
|
|
// Token-gated email-change endpoints. The confirm/cancel links land in
|
|
// a fresh browser (the user may not be signed in on this device), so
|
|
// they need to bypass the session 401 gate. The endpoints validate a
|
|
// signed sha256-hashed token instead — that's the auth.
|
|
'/api/v1/me/email/confirm/',
|
|
'/api/v1/me/email/cancel/',
|
|
];
|
|
|
|
function isPublicPath(pathname: string): boolean {
|
|
// Per-port PWA manifests sit under `/<portSlug>/scan/manifest.webmanifest`
|
|
// and need to be fetchable without a session - browsers fetch them eagerly
|
|
// during install / first paint. The manifest only contains port name +
|
|
// icon paths, no sensitive data, so making it public is safe.
|
|
if (pathname.endsWith('/scan/manifest.webmanifest')) return true;
|
|
return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
|
|
}
|
|
|
|
function isApiRoute(pathname: string): boolean {
|
|
return pathname.startsWith('/api/');
|
|
}
|
|
|
|
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
|
/**
|
|
* SameSite=Lax cookies block top-level cross-site POSTs in modern browsers,
|
|
* but defense-in-depth: every state-changing request to a session-authed
|
|
* `/api/v1/**` endpoint must originate from the same origin as the app.
|
|
* Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are
|
|
* exempt because they're called by external systems with no session
|
|
* cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle
|
|
* their own origin/CSRF checks via better-auth.
|
|
*/
|
|
function isOriginCheckedPath(pathname: string): boolean {
|
|
if (!pathname.startsWith('/api/v1/')) return false;
|
|
return true;
|
|
}
|
|
|
|
function originAllowed(request: NextRequest): boolean {
|
|
const origin = request.headers.get('origin');
|
|
const referer = request.headers.get('referer');
|
|
// Same-origin fetch from the app sends both Origin AND a matching host.
|
|
// Use request.nextUrl.origin (the deployed origin) as the source of truth.
|
|
const expectedOrigin = request.nextUrl.origin;
|
|
if (origin) return origin === expectedOrigin;
|
|
if (referer) {
|
|
try {
|
|
return new URL(referer).origin === expectedOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
// Neither header present: most browser fetches always send Origin on
|
|
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
|
|
// call (e.g. Next.js internal fetch). Allow.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Apply per-request CSP to a NextResponse. Skipped for API routes
|
|
* (they don't render HTML so script-src is irrelevant) and for
|
|
* already-built responses that have set their own CSP (e.g. redirects
|
|
* where the static next.config CSP applies).
|
|
*/
|
|
function applyCsp(response: NextResponse, nonce: string, pathname: string): NextResponse {
|
|
if (isApiRoute(pathname)) return response;
|
|
const isProd = process.env.NODE_ENV === 'production';
|
|
response.headers.set('Content-Security-Policy', buildCspWithNonce(nonce, isProd));
|
|
response.headers.set('x-nonce', nonce);
|
|
return response;
|
|
}
|
|
|
|
export function proxy(request: NextRequest): NextResponse {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Mint a per-request nonce up-front so HTML responses can carry it.
|
|
// Cheap (one UUID + base64) so we always do it; the apply step
|
|
// skips API routes that don't need it.
|
|
const nonce = generateNonce();
|
|
const isProd = process.env.NODE_ENV === 'production';
|
|
|
|
// CSRF defense-in-depth: state-changing requests to authed /api/v1
|
|
// endpoints must come from the app's own origin. Skipped in dev so
|
|
// LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while
|
|
// a Mac browser tab is loaded from localhost) doesn't trip on the
|
|
// origin mismatch. Production keeps the check.
|
|
if (
|
|
process.env.NODE_ENV !== 'development' &&
|
|
STATE_CHANGING_METHODS.has(request.method) &&
|
|
isOriginCheckedPath(pathname) &&
|
|
!originAllowed(request)
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: 'Cross-origin state-changing request rejected' },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
// Always allow public paths through
|
|
if (isPublicPath(pathname)) {
|
|
// Forward the nonce in the REQUEST header so Next's RSC bootstrap
|
|
// can read it via `headers()` and stamp it onto every inline
|
|
// <script>. The browser-facing CSP is set on the response below.
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
|
|
requestHeaders.set('x-nonce', nonce);
|
|
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
|
|
}
|
|
|
|
// M-R01: portal pages use a distinct cookie (`portal_session`) from
|
|
// the CRM (`pn-crm.session_token`). Backstop here so any future
|
|
// /portal/* page that forgets its own session check gets caught.
|
|
if (pathname.startsWith('/portal/')) {
|
|
const portalSession = request.cookies.get('portal_session');
|
|
if (!portalSession?.value) {
|
|
const loginUrl = new URL('/portal/login', request.url);
|
|
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
|
|
requestHeaders.set('x-nonce', nonce);
|
|
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
|
|
}
|
|
|
|
const sessionToken = request.cookies.get('pn-crm.session_token');
|
|
|
|
if (!sessionToken?.value) {
|
|
if (isApiRoute(pathname)) {
|
|
// API routes return 401 JSON - never redirect
|
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
|
}
|
|
|
|
// Page routes redirect to /login, preserving the intended destination
|
|
const loginUrl = new URL('/login', request.url);
|
|
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
|
|
requestHeaders.set('x-nonce', nonce);
|
|
return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except:
|
|
* - _next/static (static files)
|
|
* - _next/image (Next.js image optimisation)
|
|
* - favicon.ico (browser tab icon)
|
|
* - /images/ (public image assets)
|
|
* - manifest.json (PWA manifest — must be unauthed for installability)
|
|
* - icon-*.png (PWA + apple-touch icons referenced by manifest)
|
|
* - apple-touch-icon (iOS home-screen icon)
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
|
|
],
|
|
};
|