From f101bde6a82a8d4fbb017be98f64be402e58371e Mon Sep 17 00:00:00 2001 From: bjornbergenheim <29535152+bjornbergenheim@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:52:31 +0200 Subject: [PATCH] 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 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 45 +++++++++ DECISIONS.md | 1 + components/settings/AutoLogoutToggle.tsx | 3 +- components/settings/SecuritySettings.tsx | 3 +- lib/analytics/enabled.ts | 6 +- lib/auth/bankid.ts | 5 +- lib/auth/google-oauth.ts | 4 +- lib/auth/mfa.ts | 6 +- lib/bokslut/arsredovisning/capabilities.ts | 8 +- lib/documents/upload-size.ts | 4 +- lib/entitlements/has-capability.ts | 12 ++- lib/env/__tests__/public-flags.test.ts | 53 +++++++++++ lib/env/public-flags.ts | 65 +++++++++++++ scripts/checks/no-new-antipatterns.mjs | 104 ++++++++++++++++++++- 14 files changed, 301 insertions(+), 18 deletions(-) create mode 100644 lib/env/__tests__/public-flags.test.ts create mode 100644 lib/env/public-flags.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 037fbffb..0a145eeb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -116,6 +116,51 @@ jobs: cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }},mode=max,image-manifest=true,oci-mediatypes=true + - name: Verify NEXT_PUBLIC_* sentinels survived the build + # The image is generic: it is built with sentinel values + # (__NEXT_PUBLIC_SELF_HOSTED__) that docker-entrypoint.sh seds into + # .next at container start. That only works if the sentinel is still IN + # the build output. Writing `process.env.NEXT_PUBLIC_X === 'true'` in + # source lets the minifier fold the comparison and delete the branch, + # erasing the sentinel: the flag is then permanently false and no + # operator setting can change it. That shipped once and left every + # Docker self-host running with the entitlement paywall live, invisibly, + # because dev and the Vercel build both have real env values and never + # reproduce it. + # + # This runs in the per-platform build, not in `merge`, for two reasons. + # It is the only place each architecture is actually checked: `docker + # run` against the manifest list resolves the runner's own platform, so + # a merge-job check would silently exempt arm64. And it lands BEFORE any + # tag exists, so a folded sentinel fails the matrix (fail-fast) and + # `merge` never runs: `latest` cannot move onto a build whose flags can + # no longer be configured. The digest image pushed above stays untagged + # and unreferenced. + # + # check:guards catches the source pattern on every PR; this is the + # end-to-end proof against the built artifact, which is the only place + # the failure is observable. + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + IMAGE="${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + MISSING="" + for VAR in NEXT_PUBLIC_SELF_HOSTED NEXT_PUBLIC_REQUIRE_MFA; do + if docker run --rm --entrypoint sh "$IMAGE" -c \ + "grep -rq '__${VAR}__' /opt/gnubok-template/.next"; then + echo "ok: __${VAR}__ present in build output" + else + MISSING="${MISSING} ${VAR}" + fi + done + if [ -n "$MISSING" ]; then + echo "::error::Sentinel(s) missing from the build output:${MISSING}." + echo "::error::Read these flags via lib/env/public-flags (flagEnabled/isSelfHosted)." + echo "::error::An in-place comparison is constant-folded away, leaving the flag stuck off." + exit 1 + fi + # Digests reach `merge` as artifact filenames: the file content is # irrelevant, only the name carries information. - name: Export digest diff --git a/DECISIONS.md b/DECISIONS.md index 7b30088f..730762af 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1051,6 +1051,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line). [2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list. [2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03. +[2026-08-18] NEXT_PUBLIC_* boolean flags are read as VALUES via lib/env/public-flags (flagEnabled/isSelfHosted), never compared in place: `process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'` is constant-folded against the Docker sentinel at build time and dead-code-eliminated, erasing the sentinel docker-entrypoint.sh needs, so every Docker self-host silently ran with the entitlement paywall live (ai/bank_sync/skatteverket/email_send dying 30 days after company creation). flagEnabled uses a Set lookup rather than `===` because a minifier can fold the latter. Guarded twice: check:guards folded-public-flag (source, every PR) and a docker-publish step asserting the sentinels survive the built image (artifact, the only place the failure is observable: dev and Vercel both have real env values). [2026-08-18] Swish QR fix (RUT/ROT) scoped to buildSwishQrDataUrl only, preview-pdf left as-is: previewInvoice carries no deduction fields at all, so the editor preview is internally consistent (its QR matches its own totals block) but ignores ROT/RUT entirely; that is a separate display gap to file as its own issue rather than widening this money-math diff. [2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund). diff --git a/components/settings/AutoLogoutToggle.tsx b/components/settings/AutoLogoutToggle.tsx index 1094f12a..171f2153 100644 --- a/components/settings/AutoLogoutToggle.tsx +++ b/components/settings/AutoLogoutToggle.tsx @@ -5,10 +5,11 @@ import { useTranslations } from 'next-intl' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { SettingsRow } from '@/components/settings/SettingsRows' +import { isSelfHosted as readSelfHostedFlag } from '@/lib/env/public-flags' // Session timeouts are a hosted concern: self-hosted deployments have them // disabled at the config level, so the toggle would be a no-op there. -const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' +const isSelfHosted = readSelfHostedFlag() /** * Per-user opt-in for automatic logout. Off by default: the session then diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx index 070eabb3..99d2ce19 100644 --- a/components/settings/SecuritySettings.tsx +++ b/components/settings/SecuritySettings.tsx @@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react' import { isMfaRequired } from '@/lib/auth/mfa' import { isBankIdEnabled } from '@/lib/auth/bankid' +import { isSelfHosted as readSelfHostedFlag } from '@/lib/env/public-flags' import { AutoLogoutToggle } from '@/components/settings/AutoLogoutToggle' import { BankIdSettings } from '@/components/settings/BankIdSettings' import { userHasPassword } from '@/lib/auth/has-password' @@ -22,7 +23,7 @@ import { SettingsRowNote, } from '@/components/settings/SettingsRows' -const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' +const isSelfHosted = readSelfHostedFlag() const mfaRequired = isMfaRequired() const bankIdEnabled = isBankIdEnabled() diff --git a/lib/analytics/enabled.ts b/lib/analytics/enabled.ts index ade5ebd7..d4518837 100644 --- a/lib/analytics/enabled.ts +++ b/lib/analytics/enabled.ts @@ -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 diff --git a/lib/auth/bankid.ts b/lib/auth/bankid.ts index 636e7840..5b9b471e 100644 --- a/lib/auth/bankid.ts +++ b/lib/auth/bankid.ts @@ -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) } // --------------------------------------------------------------------------- diff --git a/lib/auth/google-oauth.ts b/lib/auth/google-oauth.ts index 02edc5d6..15e6a7be 100644 --- a/lib/auth/google-oauth.ts +++ b/lib/auth/google-oauth.ts @@ -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) } diff --git a/lib/auth/mfa.ts b/lib/auth/mfa.ts index 72230576..99dbcc5e 100644 --- a/lib/auth/mfa.ts +++ b/lib/auth/mfa.ts @@ -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) } /** diff --git a/lib/bokslut/arsredovisning/capabilities.ts b/lib/bokslut/arsredovisning/capabilities.ts index 0ca138ac..98c29feb 100644 --- a/lib/bokslut/arsredovisning/capabilities.ts +++ b/lib/bokslut/arsredovisning/capabilities.ts @@ -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 { diff --git a/lib/documents/upload-size.ts b/lib/documents/upload-size.ts index 99645c0d..84ee4319 100644 --- a/lib/documents/upload-size.ts +++ b/lib/documents/upload-size.ts @@ -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() } /** diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts index 799567ac..c0580bfe 100644 --- a/lib/entitlements/has-capability.ts +++ b/lib/entitlements/has-capability.ts @@ -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 diff --git a/lib/env/__tests__/public-flags.test.ts b/lib/env/__tests__/public-flags.test.ts new file mode 100644 index 00000000..fa6aad91 --- /dev/null +++ b/lib/env/__tests__/public-flags.test.ts @@ -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) + }) +}) diff --git a/lib/env/public-flags.ts b/lib/env/public-flags.ts new file mode 100644 index 00000000..0db74b4a --- /dev/null +++ b/lib/env/public-flags.ts @@ -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) +} diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 79a79bd9..e2321b5e 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -73,6 +73,15 @@ * and arbitrary `rounded-[Npx]` are dead vocabulary in app/ and * components/. No baseline: the count is 0, any new one is a hard * failure. + * 10. folded-public-flag: `process.env.NEXT_PUBLIC_X === 'true'` compared in + * place. The Docker image bakes sentinels that docker-entrypoint.sh + * substitutes at container start; an in-place comparison is constant- + * folded and dead-code-eliminated at build time, erasing both the name + * and the sentinel, so the flag is permanently false however the operator + * configures it. Every Docker self-host consequently ran with the + * entitlement paywall live (diagnosed 2026-08-17). Read flags as values + * via lib/env/public-flags. No baseline: the count is 0, any new one is + * a hard failure. * * Usage: * node scripts/checks/no-new-antipatterns.mjs # check (CI) @@ -408,6 +417,81 @@ function findOffLadderRadii() { return findings.sort() } +// 10. folded-public-flag. The Docker 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 the var 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 name and the sentinel vanish and sed has nothing to +// replace. The flag is then permanently false whatever the operator sets. +// +// That shipped and stayed invisible for weeks: every Docker self-host ran with +// the entitlement paywall live, killing ai/bank_sync/skatteverket/email_send 30 +// days after company creation. Read public flags as VALUES instead +// (flagEnabled(process.env.NEXT_PUBLIC_X) from lib/env/public-flags), which +// keeps the sentinel in the output as a live string literal. +// +// No baseline: the count is 0, any new one is a hard failure. +const PUBLIC_FLAG_EXEMPT = new Set(['lib/env/public-flags.ts']) + +const EQUALITY_OPS = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]) + +/** `process.env.NEXT_PUBLIC_ANYTHING` as an expression node. */ +function isPublicEnvRead(node) { + return ( + ts.isPropertyAccessExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'process' && + node.expression.name.text === 'env' && + ts.isIdentifier(node.name) && + node.name.text.startsWith('NEXT_PUBLIC_') + ) +} + +/** Public env flags compared in place, which the Docker build folds away. */ +function findFoldedPublicFlags() { + const files = [ + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'contexts'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), + ] + const findings = [] + for (const file of files) { + const relPath = rel(file) + if (PUBLIC_FLAG_EXEMPT.has(relPath)) continue + // Tests never ship in the image, and they legitimately assert on raw env. + // Both layouts: the __tests__/ convention, and a colocated *.test.ts(x), + // which would otherwise be a false positive that invites weakening this + // guard rather than fixing a real call site. + if (relPath.includes('__tests__/') || /\.test\.tsx?$/.test(relPath)) continue + const text = fs.readFileSync(file, 'utf8') + if (!text.includes('NEXT_PUBLIC_')) continue + + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) + const visit = (node) => { + if ( + ts.isBinaryExpression(node) && + EQUALITY_OPS.has(node.operatorToken.kind) && + (isPublicEnvRead(node.left) || isPublicEnvRead(node.right)) + ) { + const pos = source.getLineAndCharacterOfPosition(node.getStart(source)) + findings.push(`${relPath}:${pos.line + 1}`) + } + ts.forEachChild(node, visit) + } + visit(source) + } + return [...new Set(findings)].sort() +} + // Dependencies pinned to an EXACT version on purpose, because a bump broke prod // and must not silently return via `npm update`, a dependabot bump, or a manual // install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md. @@ -749,6 +833,7 @@ const current = { sekLabelledAmounts: findSekLabelledFxAmounts(ROOT), extensionRoutes: findExtensionRouteFindings(ROOT), offLadderRadii: findOffLadderRadii(), + foldedPublicFlags: findFoldedPublicFlags(), } const isUpdate = process.argv.includes('--update') @@ -888,6 +973,23 @@ if (current.offLadderRadii.length) { ) } +// 1e1b. folded-public-flag: no baseline, the count is 0 and any new in-place +// comparison is a hard failure. This one is invisible in dev and in the Vercel +// build (both have real env values); it only misfires in the Docker image, and +// then silently. +if (current.foldedPublicFlags.length) { + failed = true + console.error( + `\n✗ folded-public-flag: ${current.foldedPublicFlags.length} NEXT_PUBLIC_* flag(s) compared in place:`, + ) + current.foldedPublicFlags.forEach((f) => console.error(` ${f}`)) + console.error( + ' → read the value instead: flagEnabled(process.env.NEXT_PUBLIC_X) from @/lib/env/public-flags\n' + + ' (or isSelfHosted()). Comparing in place lets the minifier fold the Docker sentinel to\n' + + ' false and delete the branch, so the flag can never be switched on by an operator.', + ) +} + // 1e2. hand-rolled-invariant: counted, may only go down. if (current.handRolledInvariants > (baseline.handRolledInvariants?.count ?? Infinity)) { failed = true @@ -999,5 +1101,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`, )