The image is built once with sentinel values
(ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__) that
docker-entrypoint.sh seds into .next at container start. Comparing a flag in
place defeats that: the bundler inlines the sentinel, the minifier folds
"__NEXT_PUBLIC_SELF_HOSTED__" === 'true' to false and eliminates the branch, so
both the variable name and the sentinel disappear and sed has nothing left to
replace. The flag is then permanently false whatever the operator configures.
Diagnosed against a running self-hosted instance: the compiled gate read
function r(){return"true"!==process.env.FORCE_PAYWALL
&&"true"===process.env.DISABLE_PAYWALL}
with the isSelfHosted() branch gone. The un-prefixed FORCE_PAYWALL /
DISABLE_PAYWALL survived precisely because they are never inlined, and
NODE_ENV === 'development' was folded away by the same mechanism. The one
place the flag still worked, getSessionTimeoutConfig(env = process.env), reads
it off a parameter the bundler cannot fold.
Consequence: every Docker self-host ran with the entitlement paywall live, so
ai, bank_sync, skatteverket and email_send went dark 30 days after company
creation when the seeded trial grants expired. Nothing surfaced it, because
dev and the Vercel build both have real env values and never reproduce it.
Analytics, forced MFA, BankID and the hosted upload ceiling read the same flag
and were wrong in the same direction.
Flags are now read as values through lib/env/public-flags, which keeps the
sentinel in the output as a live string literal and defers the comparison to
runtime. flagEnabled uses a Set lookup rather than ===, which a minifier could
fold if it ever inlined the helper.
Guarded twice, because the source fix alone would not have caught this:
- check:guards folded-public-flag fails any in-place NEXT_PUBLIC_* comparison
(AST, no baseline, verified to fire on a probe file);
- docker-publish asserts the sentinels survive the built image, which is the
only artifact where the failure is observable.
npm test 14999 passed, npm run lint 0 errors, npm run check:guards clean.
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
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>