Files
accounted/next.config.ts
T
Jakob Wennberg 248d98bd7e feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)
* feat(analytics): remove Recapt, PostHog is now the only analytics

Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.

Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.

The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.

Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.

Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analytics): purge Recapt storage left on users' devices

Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.

Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.

Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.

Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:08:32 +02:00

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'",
// No analytics hosts here on purpose. PostHog replaced Recapt and is
// routed through the same-origin `/rl` rewrite below, so ingestion is
// covered by `connect-src 'self'` and its lazy-loaded replay/survey
// bundles by `script-src 'self'`. Adding `*.posthog.com` back would
// re-widen the policy for no benefit and undo the ad-blocker resistance.
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
"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);