d4f82cafc4
Recapt shuts down in four days, taking product analytics and session replay with it. This adds PostHog Cloud EU alongside it; the Recapt removal follows separately so events can be confirmed landing first. Wiring choices that are not the tutorial defaults: - Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding PostHog hosts to the CSP. connect-src 'self' and script-src 'self' already cover it, tracking blockers have no third-party host to match, and the Recapt allowlist entries in next.config.ts get replaced by nothing at all when they go. Needs skipTrailingSlashRedirect, since PostHog sends trailing-slash API requests; verified that trailing-slash URLs on normal routes still resolve 200 rather than 404. - /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE next.config rewrites, so without this updateSession() treats an ingestion POST as an unknown protected path and 307s it to /login. Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200 from PostHog. This fails silently otherwise, because asset loads keep working through the rewrite while no events arrive. - persistence: 'memory' so nothing is written to the device and no cookie-consent banner is required. Everything post-login is unaffected: AnalyticsIdentify re-identifies on each dashboard load. - session_recording.maskTextSelector: '*'. PostHog masks inputs but not text by default, and this app renders org numbers (which for an enskild firma ARE the owner's personnummer), customer names and balances as ordinary text. Replays show where a user gets stuck, never what their books say. buildGroupProperties() also refuses to send org_number at all, with a test pinning it. - Error tracking registers through the existing lib/observability sink rather than bypassing it, so every error-level createLogger() line is captured already redacted. instrumentation.ts onRequestError covers what escapes uncaught. Analytics is hosted-only: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted runs with zero third-party runtime code. Recapt got that outcome only by accident, via a missing sentinel; here it is explicit and tested. vitest.config.ts aliases 'server-only' to a stub: it is a build-time guard whose real entry point always throws, which broke 48 test files the moment a server-only module entered the graph. request-context.ts was already carrying the same latent trap. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { NextConfig } from "next";
|
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
import { LEGACY_HOST_REDIRECT_EXCLUSIONS } from "./lib/domains/legacy-redirect";
|
|
|
|
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|
|
|
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
|
|
|
// WebSocket origin for Supabase Realtime. Hosted projects are covered by the
|
|
// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not:
|
|
// Realtime opens wss://<supabase-host>/realtime/v1/websocket, and WebKit
|
|
// throws synchronously on a CSP-blocked `new WebSocket()`, unmounting the
|
|
// dashboard into the error boundary (issue #893). The Docker image bakes the
|
|
// __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel at build time and
|
|
// docker-entrypoint.sh substitutes the real value at runtime (a build-time
|
|
// https-to-wss replace would only rewrite the sentinel); the fallback derives
|
|
// wss:/ws: from the https/http URL for non-Docker builds where the real URL
|
|
// is present at build time. Empty supabaseUrl stays empty, mirroring how
|
|
// ${supabaseUrl} is interpolated below (extra whitespace is valid in CSP).
|
|
const supabaseWsUrl =
|
|
process.env.NEXT_PUBLIC_SUPABASE_WS_URL ??
|
|
supabaseUrl.replace(/^http(s?):/, "ws$1:");
|
|
|
|
const cspDirectives = [
|
|
"default-src 'self'",
|
|
// Recapt: scoped to the two specific hosts the SDK actually contacts:
|
|
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
|
|
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
|
|
// exfiltration to any subdomain of recapt.app and is intentionally
|
|
// narrowed.
|
|
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
|
|
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
|
|
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
|
|
"img-src 'self' data: blob: https:",
|
|
"font-src 'self'",
|
|
"worker-src 'self' blob:",
|
|
// object-src must explicitly allow blob:: Chrome's built-in PDF viewer
|
|
// renders inline PDFs via an internal <embed>, which falls under
|
|
// object-src. Without this, blob:-URL invoice previews (created via
|
|
// URL.createObjectURL on /api/invoices/preview-pdf responses) show
|
|
// "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
|
|
// Edge uses its own viewer, so neither hits this. See crbug.com/271452.
|
|
"object-src 'self' blob:",
|
|
`frame-src 'self' blob: ${supabaseUrl}`,
|
|
"frame-ancestors 'none'",
|
|
].join("; ");
|
|
|
|
const nextConfig: NextConfig = {
|
|
output: 'standalone',
|
|
// Build id inlined into the client bundle so a running tab can tell when a
|
|
// newer deploy is live (see components/system/DeployReloadPrompt). On Vercel
|
|
// this is the commit SHA; empty elsewhere (dev / self-hosted), which disables
|
|
// the check. The /api/version route reads the same var at runtime to compare.
|
|
env: {
|
|
NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '',
|
|
},
|
|
// Multiple lockfiles exist above this project (e.g. a parent yarn.lock),
|
|
// which makes Turbopack infer the wrong workspace root. Pin it explicitly.
|
|
turbopack: {
|
|
root: projectRoot,
|
|
},
|
|
// PostHog sends trailing-slash API requests; without this Next 308s them
|
|
// and the events are lost. Required by the reverse proxy below.
|
|
skipTrailingSlashRedirect: true,
|
|
experimental: {
|
|
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
|
|
},
|
|
// PostHog reverse proxy. Keeping analytics same-origin buys three things:
|
|
// the strict CSP below needs NO posthog hosts (`connect-src 'self'` already
|
|
// covers ingestion, `script-src 'self'` the lazy-loaded replay/survey
|
|
// bundles), tracking blockers have no third-party host to match, and the
|
|
// Recapt host allowlist is replaced by nothing at all.
|
|
//
|
|
// `/rl` is deliberately meaningless: PostHog's own guidance is that obvious
|
|
// prefixes (/analytics, /tracking, /telemetry, /posthog, and increasingly
|
|
// /ingest) are on blocker filter lists. It must stay in sync with `api_host`
|
|
// in instrumentation-client.ts AND with the matcher exclusion in proxy.ts,
|
|
// or middleware redirects the ingestion POSTs to /login.
|
|
//
|
|
// Both /static/* and /array/* must point at the ASSETS origin, not the
|
|
// ingestion origin: array/ serves the config bundle and is easy to miss.
|
|
async rewrites() {
|
|
return [
|
|
{
|
|
source: '/rl/static/:path*',
|
|
destination: 'https://eu-assets.i.posthog.com/static/:path*',
|
|
},
|
|
{
|
|
source: '/rl/array/:path*',
|
|
destination: 'https://eu-assets.i.posthog.com/array/:path*',
|
|
},
|
|
{
|
|
source: '/rl/:path*',
|
|
destination: 'https://eu.i.posthog.com/:path*',
|
|
},
|
|
]
|
|
},
|
|
async redirects() {
|
|
const appUrlForRedirect = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, '')
|
|
return [
|
|
{
|
|
source: '/nyckeltal',
|
|
destination: '/kpi',
|
|
permanent: true,
|
|
},
|
|
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
|
|
// v1 error envelope still points at this host; the 308 forwards both
|
|
// humans and agents to the docs subdomain without us needing to
|
|
// mass-update structured-errors.
|
|
{
|
|
source: '/docs/api',
|
|
destination: 'https://docs.gnubok.se/',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/docs/api/:path*',
|
|
destination: 'https://docs.gnubok.se/:path*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/llms-full.txt',
|
|
destination: 'https://docs.gnubok.se/llms-full.txt',
|
|
permanent: true,
|
|
},
|
|
// Dual-domain cutover (2026-07): the user-facing app moves to
|
|
// app.accounted.se; app.gnubok.se stays alive for machine traffic
|
|
// (MCP connectors, API keys, the Skatteverket OAuth callback,
|
|
// webhooks, crons). Only browser page traffic is forwarded: /api and
|
|
// /.well-known must keep answering on the legacy host, and /_next is
|
|
// excluded so already-open tabs keep loading assets until their next
|
|
// navigation. The redirect arms itself only once NEXT_PUBLIC_APP_URL
|
|
// points somewhere other than the legacy host, so merging this is
|
|
// inert and the actual cutover is the env flip + redeploy. Kept
|
|
// non-permanent until the cutover has soaked.
|
|
//
|
|
// auth/ and reset-password are excluded so email links that carry a
|
|
// PKCE code (password reset, signup confirmation) sent before the
|
|
// cutover still complete on the legacy host, where their code
|
|
// verifier / recovery-session cookies live (#1092). login and MFA
|
|
// pages are deliberately NOT excluded: serving a usable login page
|
|
// on the legacy host would establish sessions there and bounce
|
|
// users in a redirect loop.
|
|
...(appUrlForRedirect &&
|
|
appUrlForRedirect.startsWith('https://') &&
|
|
!appUrlForRedirect.includes('app.gnubok.se')
|
|
? [
|
|
{
|
|
source: `/:path(${LEGACY_HOST_REDIRECT_EXCLUSIONS}.*)`,
|
|
has: [{ type: 'host' as const, value: 'app.gnubok.se' }],
|
|
destination: `${appUrlForRedirect}/:path`,
|
|
permanent: false,
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
},
|
|
async headers() {
|
|
// The catch-all excludes /api/documents/:id/inline so the strict
|
|
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
|
|
// the embeddable override below: Next.js applies every matching
|
|
// header rule, and duplicate X-Frame-Options/CSP values trigger
|
|
// "Det här innehållet har blockerats" in Chromium browsers.
|
|
return [
|
|
{
|
|
source: "/((?!api/documents/[^/]+/inline$).*)",
|
|
headers: [
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{
|
|
key: "X-Frame-Options",
|
|
value: "DENY",
|
|
},
|
|
{
|
|
key: "X-Content-Type-Options",
|
|
value: "nosniff",
|
|
},
|
|
{
|
|
key: "Referrer-Policy",
|
|
value: "strict-origin-when-cross-origin",
|
|
},
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy",
|
|
value: cspDirectives,
|
|
},
|
|
],
|
|
},
|
|
// Document inline-preview proxy must be embeddable in same-origin
|
|
// iframes (used by the verifikat document preview Sheet). Excluded
|
|
// from the catch-all above so these values aren't shadowed by the
|
|
// stricter defaults.
|
|
//
|
|
// CSP is intentionally minimal: only `frame-ancestors 'self'`
|
|
// prevents cross-origin clickjacking on the user's documents.
|
|
// Adding `object-src 'none'` (or `default-src 'none'`) here breaks
|
|
// Chrome's built-in PDF viewer: Chrome renders inline PDFs through
|
|
// an internal <embed>, which the directive forbids, surfacing as
|
|
// "Det här innehållet har blockerats" in the document preview Sheet.
|
|
// Firefox uses PDF.js and Edge uses its own viewer, so neither hits
|
|
// this. See crbug.com/271452. X-Content-Type-Options: nosniff plus
|
|
// the explicit Content-Type from the route handler already prevent
|
|
// MIME-confusion abuse.
|
|
{
|
|
source: "/api/documents/:id/inline",
|
|
headers: [
|
|
{
|
|
key: "Strict-Transport-Security",
|
|
value: "max-age=63072000; includeSubDomains; preload",
|
|
},
|
|
{
|
|
key: "X-Frame-Options",
|
|
value: "SAMEORIGIN",
|
|
},
|
|
{
|
|
key: "X-Content-Type-Options",
|
|
value: "nosniff",
|
|
},
|
|
{
|
|
key: "Referrer-Policy",
|
|
value: "strict-origin-when-cross-origin",
|
|
},
|
|
{
|
|
key: "Permissions-Policy",
|
|
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
|
},
|
|
{
|
|
key: "Content-Security-Policy",
|
|
value: "frame-ancestors 'self'",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export default withNextIntl(nextConfig);
|