feat(ops): read DEMO_MODE from the stack rather than the compose file (#190)

Reverses the position the previous commit took. Hardcoding it made a value that gets flipped without a code change require a commit and a merge to flip, which is backwards — and it is the operator's call, not the file's.

No default, deliberately. `${DEMO_MODE:-false}` is the obvious form and the wrong one: a default decides whether the shop takes money on the operator's behalf, silently, whichever way it points. Having none is safe rather than fragile because `checkDemoMode` is strict — an unset stack variable substitutes to an empty string, and anything that is not exactly `true` or `false` refuses to boot naming DEMO_MODE. That strictness is the whole reason interpolating this one is defensible, so the line says so.

The compose guard had to learn the difference. It hands each deploying file's entries to the real validator, and a literal `${DEMO_MODE}` is not a value `checkDemoMode` accepts, so both the DEMO_MODE assertion and the validateEnv check failed the moment the file stopped holding a literal. Each deployment now declares the stack variables it supplies, and a bare `${VAR}` named there resolves to the declared value before the file is validated. Every other bare `${VAR}` stays opaque exactly as before — those are secrets, and what is checked of them is that the line exists.

Be clear about what that guard can prove. It cannot see Portainer, so it does not verify the stack actually holds `true`; nothing in this repository can. What it does is keep the intent beside the file and make the pair inseparable — hardcode the compose line and the registry disagrees, change the registry and it no longer describes the file. The runtime half is the boot check, which fails loudly rather than falling back. Verified by mutation: hardcoding `false` fails the DEMO_MODE assertion, and deleting the line fails that and `validateEnv`.

Restoring real payments is now two Portainer values and a redeploy, with no commit — which is what #190 asks for.
This commit is contained in:
2026-08-26 09:17:20 -05:00
parent 12b2f09d79
commit 6104ebb459
2 changed files with 62 additions and 18 deletions
+40 -6
View File
@@ -48,6 +48,10 @@ const DEPLOYMENTS = [
{
file: 'docker-compose.qa.yml',
demoMode: 'true',
// Written into the file rather than supplied by the stack, unlike
// production: QA exists to run without PayPal credentials, so there is no
// circumstance in which it should be anything else.
stackVariables: {},
// The entire safety property of QA: delivery is restricted to named
// recipients, so a run against a database full of test fixtures cannot
// email a real customer. Asserted because the compose file claims it must
@@ -59,10 +63,20 @@ const DEPLOYMENTS = [
// TEMPORARY, and #190 restores it to 'false'. Production is in demo mode
// since 2026-08-25 — the stack was brought up during the cutover to this
// file before the live PayPal credentials were available, and demo mode is
// the sanctioned interim for that. This assertion is what keeps the state
// honest: it fails the moment the compose file and this expectation
// disagree, so neither can be changed quietly, in either direction.
// the sanctioned interim for that.
demoMode: 'true',
// Production reads DEMO_MODE from the stack, so the file holds `${DEMO_MODE}`
// rather than a value and this is what the stack has to be set to.
//
// Be clear about what this can and cannot prove. It cannot see Portainer,
// so it does not verify the stack actually holds `true` — nothing in this
// repository can. What it does is keep the intent written down beside the
// file, and make the pair inseparable: change the compose line to a literal
// and this disagrees, change this and the boot behaviour it claims no
// longer matches. The runtime half is `checkDemoMode`, which refuses to
// start on anything that is not exactly `true` or `false`, so a stack that
// is unset or mistyped fails loudly rather than falling back to either.
stackVariables: { DEMO_MODE: 'true' },
// Deliberately unrestricted. Production has to be able to reach real
// customers, and it is the one environment where that is correct.
requiresMailAllowlist: false
@@ -84,9 +98,29 @@ function resolveDefault(value: string): string {
return withDefault?.[1] ?? value;
}
/**
* What the container receives for one entry, given what the stack is declared
* to set.
*
* A bare `${VAR}` the deployment names in `stackVariables` resolves to that
* value — which is how a file that reads DEMO_MODE from the stack can still be
* handed to `validateEnv` and checked as the container will see it. Every other
* bare `${VAR}` stays opaque, as before: those are secrets, and what is being
* checked of them is that the line exists.
*/
function resolveEntry(value: string, stackVariables: Readonly<Record<string, string>>): string {
const bare = /^\$\{([A-Z_0-9]+)\}$/.exec(value);
const name = bare?.[1];
const named = name === undefined ? undefined : stackVariables[name];
return named ?? resolveDefault(value);
}
// Only real environment entries — `- NAME=value` at an indented list position.
// A mention inside a comment cannot match, because a comment line starts with #.
function environmentEntries(source: string): Map<string, string> {
function environmentEntries(
source: string,
stackVariables: Readonly<Record<string, string>>
): Map<string, string> {
const entries = new Map<string, string>();
// The split pattern tolerates carriage returns. A checkout with CRLF line
// endings, which is every fresh clone on Windows, otherwise leaves a stray
@@ -100,7 +134,7 @@ function environmentEntries(source: string): Map<string, string> {
// but RegExpExecArray cannot say so, and #101 made the compiler insist.
const [, name, value] = match ?? [];
if (name !== undefined && value !== undefined) {
entries.set(name, resolveDefault(value.trim()));
entries.set(name, resolveEntry(value.trim(), stackVariables));
}
}
return entries;
@@ -108,7 +142,7 @@ function environmentEntries(source: string): Map<string, string> {
describe.each(DEPLOYMENTS)('$file provides everything the app requires to boot', (deployment) => {
const compose = readFileSync(path.join(REPO_ROOT, deployment.file), 'utf8');
const entries = environmentEntries(compose);
const entries = environmentEntries(compose, deployment.stackVariables);
it('parsed some environment entries at all', () => {
// Guards the guard: a regex that matched nothing would make every
+22 -12
View File
@@ -58,6 +58,12 @@
# Required stack environment variables — all secrets, all must be set in
# Portainer for this stack:
#
# DEMO_MODE `true` or `false`, exactly. Whether real payments are
# taken. Not a secret — it is here rather than written
# into the file below because it is the one value that
# gets flipped without a code change. There is no
# default: unset or mistyped refuses to boot rather than
# choosing for you.
# DB_PASSWORD Postgres password for the `redefined` database.
# SMTP_USER Brevo SMTP login.
# SMTP_PASSWORD
@@ -112,24 +118,28 @@ services:
# on 2026-08-25 to bring the stack up during the cutover to this file
# before the live PayPal credentials were to hand.
#
# Restoring it is #190. Set this back to `false`, fill the three stack
# variables below, and redeploy. Until then every order placed is a
# pretend one.
# Restoring it is #190: fill the three PayPal stack variables and set the
# DEMO_MODE stack variable to `false`. Both are Portainer values now, so
# restoring real payments needs no commit — which is the point, and also
# why this banner and the guard test exist to keep the state visible.
# Until then every order placed is a pretend one.
# ===================================================================
#
# Real payments. This is the difference between production and QA, and it
# is why the three PayPal secrets are required rather than optional — the
# app refuses to start without them when this is false.
#
# HARDCODED, AND NOT INTERPOLATED, ON PURPOSE. Portainer substitutes stack
# variables into this file; it does not hand them to the container. A
# `DEMO_MODE` stack variable therefore does NOTHING — there is no `${...}`
# here for it to substitute into, and the value below wins silently. That
# is deliberate: the one value that decides whether the shop takes money
# should not be flippable from a web UI without a commit anybody can read.
# It is also exactly what wasted an hour during the cutover, so it is
# written down rather than left to be rediscovered.
- DEMO_MODE=true
# Supplied by the stack, and with NO DEFAULT, deliberately.
# `${DEMO_MODE:-false}` is the obvious thing to write and the wrong thing:
# a default decides whether the shop takes money on the operator's behalf,
# silently, whichever way it points.
#
# Having no default is safe rather than fragile, because `checkDemoMode`
# is strict. An unset stack variable substitutes to an empty string, and
# anything that is not exactly `true` or `false` refuses to boot naming
# DEMO_MODE. So a missing or mistyped value fails loudly at startup
# instead of guessing — which is what makes interpolating this one safe.
- DEMO_MODE=${DEMO_MODE}
- PAYPAL_ENV=live
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
- PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}