fix(selfhost): stop NEXT_PUBLIC_* flags being constant-folded out of the Docker build (#1656)
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>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
bd85395cd6
commit
f101bde6a8
@@ -12,10 +12,12 @@
|
||||
* short-circuits first, then the feature's own env var decides.
|
||||
*/
|
||||
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
|
||||
export const POSTHOG_TOKEN_VAR = 'NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN'
|
||||
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
if (isSelfHosted()) return false
|
||||
return Boolean(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN)
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ export function warnIfAnalyticsMisconfigured(): boolean {
|
||||
if (isAnalyticsEnabled()) return true
|
||||
|
||||
// Self-hosted is a deliberate off, not a misconfiguration: stay quiet.
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
if (isSelfHosted()) return false
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// console, not createLogger(): this runs in the browser from
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { flagEnabled, isSelfHosted } from '@/lib/env/public-flags'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
@@ -14,8 +15,8 @@ const ALGORITHM = 'aes-256-gcm'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isBankIdEnabled(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_BANKID_ENABLED === 'true'
|
||||
if (isSelfHosted()) return false
|
||||
return flagEnabled(process.env.NEXT_PUBLIC_BANKID_ENABLED)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* Unlike BankID this is not hosted-only: self-hosted installations can
|
||||
* configure their own Google OAuth client.
|
||||
*/
|
||||
import { flagEnabled } from '@/lib/env/public-flags'
|
||||
|
||||
export function isGoogleAuthEnabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === 'true'
|
||||
return flagEnabled(process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED)
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,9 +5,11 @@
|
||||
* Enforcement is application-side (middleware + API routes), not RLS.
|
||||
*/
|
||||
|
||||
import { flagEnabled, isSelfHosted } from '@/lib/env/public-flags'
|
||||
|
||||
export function isMfaRequired(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_REQUIRE_MFA === 'true'
|
||||
if (isSelfHosted()) return false
|
||||
return flagEnabled(process.env.NEXT_PUBLIC_REQUIRE_MFA)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { flagEnabled } from '@/lib/env/public-flags'
|
||||
import type { AnnualReportEligibilityResult, AnnualReportFramework } from './compliance-types'
|
||||
|
||||
export interface AnnualReportCapabilities {
|
||||
@@ -17,14 +18,15 @@ export interface AnnualReportCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
export const CONNECTED_FILING_PUBLIC_RELEASED =
|
||||
process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
|
||||
export const CONNECTED_FILING_PUBLIC_RELEASED = flagEnabled(
|
||||
process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED,
|
||||
)
|
||||
|
||||
export function getAnnualReportCapabilities(
|
||||
framework: AnnualReportFramework,
|
||||
eligibility?: AnnualReportEligibilityResult,
|
||||
): AnnualReportCapabilities {
|
||||
const releaseGateOpen = process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED === 'true'
|
||||
const releaseGateOpen = flagEnabled(process.env.NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED)
|
||||
const ixbrlEnabled = framework === 'k2'
|
||||
const eligible = eligibility?.digital_filing_eligible ?? false
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* governs there and none of this applies.
|
||||
*/
|
||||
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
|
||||
/** The platform's hard ceiling on a request body. */
|
||||
export const HOSTED_REQUEST_BODY_LIMIT_BYTES = Math.round(4.5 * 1024 * 1024)
|
||||
|
||||
@@ -26,7 +28,7 @@ export const HOSTED_REQUEST_BODY_LIMIT_BYTES = Math.round(4.5 * 1024 * 1024)
|
||||
export const HOSTED_MAX_UPLOAD_BYTES = 4 * 1024 * 1024
|
||||
|
||||
export function isHostedDeployment(): boolean {
|
||||
return process.env.NEXT_PUBLIC_SELF_HOSTED !== 'true'
|
||||
return !isSelfHosted()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { PAID_CAPABILITIES, type CapabilityKey } from './keys'
|
||||
|
||||
/**
|
||||
@@ -18,10 +19,13 @@ import { PAID_CAPABILITIES, type CapabilityKey } from './keys'
|
||||
* validated API key for MCP): never taken from untrusted input here.
|
||||
*/
|
||||
|
||||
/** Self-hosted deployments are all-on: the gate never withholds anything. */
|
||||
function isSelfHosted(): boolean {
|
||||
return process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
}
|
||||
/**
|
||||
* Self-hosted deployments are all-on: the gate never withholds anything.
|
||||
*
|
||||
* Read through lib/env/public-flags: comparing process.env.NEXT_PUBLIC_* in
|
||||
* place gets constant-folded out of the Docker build, which is exactly how
|
||||
* every self-hosted install ended up running behind this paywall.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Local development is all-on so every gated feature is testable without a
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { flagEnabled, isSelfHosted } from '../public-flags'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('flagEnabled', () => {
|
||||
it('is true only for the exact string "true"', () => {
|
||||
expect(flagEnabled('true')).toBe(true)
|
||||
expect(flagEnabled('false')).toBe(false)
|
||||
expect(flagEnabled('')).toBe(false)
|
||||
expect(flagEnabled(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not accept near-misses that would silently switch a flag on', () => {
|
||||
// An operator typing TRUE/1/yes gets the documented default, not a guess.
|
||||
expect(flagEnabled('TRUE')).toBe(false)
|
||||
expect(flagEnabled('True')).toBe(false)
|
||||
expect(flagEnabled('1')).toBe(false)
|
||||
expect(flagEnabled('yes')).toBe(false)
|
||||
expect(flagEnabled(' true')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unsubstituted Docker sentinel as off', () => {
|
||||
// The image is built with __NEXT_PUBLIC_SELF_HOSTED__ and the entrypoint
|
||||
// seds the real value in. If substitution ever fails, the flag must read
|
||||
// off rather than matching some truthy heuristic.
|
||||
expect(flagEnabled('__NEXT_PUBLIC_SELF_HOSTED__')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the value the entrypoint substituted', () => {
|
||||
expect(flagEnabled('true')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSelfHosted', () => {
|
||||
it('follows NEXT_PUBLIC_SELF_HOSTED at call time, not at module load', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
expect(isSelfHosted()).toBe(true)
|
||||
|
||||
// Same module instance, different env: proves the read is not frozen into
|
||||
// a module-level constant, which is what lets the Docker entrypoint's
|
||||
// runtime substitution take effect at all.
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
expect(isSelfHosted()).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the variable is absent (hosted is the default)', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', undefined)
|
||||
expect(isSelfHosted()).toBe(false)
|
||||
})
|
||||
})
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Runtime reads of `NEXT_PUBLIC_*` boolean flags.
|
||||
*
|
||||
* The Docker image is generic: it is built once with sentinel values
|
||||
* (`ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__` in the Dockerfile)
|
||||
* and `docker-entrypoint.sh` seds the operator's real values into `.next` at
|
||||
* container start. That contract has one requirement nobody wrote down: the
|
||||
* sentinel must still BE in the build output for sed to find.
|
||||
*
|
||||
* Writing `process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'` breaks it. The
|
||||
* bundler inlines the sentinel, leaving `"__NEXT_PUBLIC_SELF_HOSTED__" ===
|
||||
* 'true'`, which the minifier constant-folds to `false` and then
|
||||
* dead-code-eliminates. Variable name and sentinel both disappear, sed has
|
||||
* nothing to replace, and the flag is permanently false no matter what the
|
||||
* operator configures.
|
||||
*
|
||||
* That shipped: 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 (diagnosed 2026-08-17 against a running NAS instance, where
|
||||
* the compiled gate read `function r(){return"true"!==process.env.FORCE_PAYWALL
|
||||
* &&"true"===process.env.DISABLE_PAYWALL}` with the self-hosted branch gone).
|
||||
* The un-prefixed `FORCE_PAYWALL`/`DISABLE_PAYWALL` survived precisely because
|
||||
* they are never inlined.
|
||||
*
|
||||
* The fix is to keep the sentinel out of a foldable comparison. Reading it as a
|
||||
* VALUE (a function argument) preserves the string literal in the bundle; the
|
||||
* comparison then happens at runtime, after sed has done its work. The Set
|
||||
* lookup is the belt to that suspenders: a minifier can fold `x === 'true'`,
|
||||
* but not `SET.has(x)`.
|
||||
*
|
||||
* Use `flagEnabled(process.env.NEXT_PUBLIC_WHATEVER)` for any public boolean
|
||||
* flag. `scripts/checks/no-new-antipatterns.mjs` (folded-public-flag) fails the
|
||||
* build on a direct comparison so this cannot regress silently again.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Values that mean "on". Deliberately a Set: `x === 'true'` is foldable when
|
||||
* `x` is a build-time constant, `TRUTHY_VALUES.has(x)` is not.
|
||||
*/
|
||||
const TRUTHY_VALUES = new Set(['true'])
|
||||
|
||||
/**
|
||||
* Whether a `NEXT_PUBLIC_*` flag is switched on.
|
||||
*
|
||||
* Pass the env read as an argument, never compare it in place:
|
||||
*
|
||||
* flagEnabled(process.env.NEXT_PUBLIC_BANKID_ENABLED) // correct
|
||||
* process.env.NEXT_PUBLIC_BANKID_ENABLED === 'true' // folded away in Docker
|
||||
*/
|
||||
export function flagEnabled(value: string | undefined): boolean {
|
||||
return value !== undefined && TRUTHY_VALUES.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this is a self-hosted deployment (Docker), as opposed to the hosted
|
||||
* product. Self-hosted disables forced MFA, session timeouts, analytics and the
|
||||
* entitlement paywall, and lifts the hosted upload ceiling.
|
||||
*
|
||||
* Named accessor rather than a bare `flagEnabled` call because five modules ask
|
||||
* this same question and the answer decides legal-ish behaviour (what an AGPL
|
||||
* operator's own instance is allowed to do without paying us).
|
||||
*/
|
||||
export function isSelfHosted(): boolean {
|
||||
return flagEnabled(process.env.NEXT_PUBLIC_SELF_HOSTED)
|
||||
}
|
||||
Reference in New Issue
Block a user