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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user