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).`, )