From d79aad0adf1675cd74fa5cef7eccb1b3076c11bb Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 26 Aug 2026 08:38:05 -0500 Subject: [PATCH 1/3] feat(ops): put production in demo mode to complete the cutover (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production could not boot during the cutover to the committed compose file: `DEMO_MODE` is false there, which makes the three PayPal secrets required, and they were not available. Demo mode is the interim the compose file's own header sanctions for exactly this — the whole cart and checkout flow works and nobody is ever charged. Two things made this cost more than it should have, and both are now written down rather than left to be rediscovered. `DEMO_MODE` is hardcoded rather than interpolated, so setting a `DEMO_MODE` stack variable in Portainer does nothing at all — there is no `${...}` for it to substitute into and the file's value wins silently. That hardcoding is right: the one value deciding whether the shop takes money should not be flippable from a web UI without a commit anybody can read. But the failure mode reads as "I set it and it ignored me", so the line now says so. Declaring `PAYPAL_CLIENT_ID=` with an empty value is identical to not declaring it. `isPresent` rejects a blank string deliberately, because set-to-nothing is a mistake rather than a value. The state is loud in both places that can see it. The compose file leads with a banner saying production is taking no money, and `composeEnvironment.test.ts` asserts `DEMO_MODE` is `true` — that assertion is the guard, not a formality: it fails the moment the file and the expectation disagree, in either direction, so this cannot be switched back quietly and cannot be left on unnoticed. #191 restores it. --- backend/tests/unit/composeEnvironment.test.ts | 8 +++++- docker-compose.prod.yml | 27 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/tests/unit/composeEnvironment.test.ts b/backend/tests/unit/composeEnvironment.test.ts index 6b998e8..946a381 100644 --- a/backend/tests/unit/composeEnvironment.test.ts +++ b/backend/tests/unit/composeEnvironment.test.ts @@ -56,7 +56,13 @@ const DEPLOYMENTS = [ }, { file: 'docker-compose.prod.yml', - demoMode: 'false', + // TEMPORARY, and #191 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. + demoMode: 'true', // Deliberately unrestricted. Production has to be able to reach real // customers, and it is the one environment where that is correct. requiresMailAllowlist: false diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 49342e2..896f347 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -104,15 +104,32 @@ services: - PGPASSWORD=${DB_PASSWORD} - PGDATABASE=redefined + # =================================================================== + # PRODUCTION IS IN DEMO MODE. IT IS TAKING NO MONEY. + # + # The whole cart and checkout flow works end to end and NOBODY IS EVER + # CHARGED. This is the deliberate interim the note below describes, taken + # 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 #191. Set this back to `false`, fill the three stack + # variables below, and redeploy. 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. # - # To bring the stack up before PayPal is configured, set this to `true` - # and the three PAYPAL_ lines can be removed. The full cart and checkout - # flow then works end to end and NOBODY IS EVER CHARGED. That is a - # deliberate interim state and a quiet disaster if it is left on. - - DEMO_MODE=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 - PAYPAL_ENV=live - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET} -- 2.54.0 From 2de61df8f706089858c6c4aa65f043a67cffb013 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 26 Aug 2026 08:38:59 -0500 Subject: [PATCH 2/3] docs(ops): point the demo-mode notes at the right issue (#190) The tracking issue was filed as #190; the compose banner and the guard test both said #191, guessing the number before it existed. A note that points at the wrong issue is worse than no note when the thing it tracks is production not taking money. --- backend/tests/unit/composeEnvironment.test.ts | 2 +- docker-compose.prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/unit/composeEnvironment.test.ts b/backend/tests/unit/composeEnvironment.test.ts index 946a381..fe0fb06 100644 --- a/backend/tests/unit/composeEnvironment.test.ts +++ b/backend/tests/unit/composeEnvironment.test.ts @@ -56,7 +56,7 @@ const DEPLOYMENTS = [ }, { file: 'docker-compose.prod.yml', - // TEMPORARY, and #191 restores it to 'false'. Production is in demo mode + // 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 896f347..7db6f41 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -112,7 +112,7 @@ 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 #191. Set this back to `false`, fill the three stack + # 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. # =================================================================== -- 2.54.0 From 7057f9ece061a2fbf8df2fc39a3aa8258962a53d Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 26 Aug 2026 08:50:42 -0500 Subject: [PATCH 3/3] feat(ops): read DEMO_MODE from the stack rather than the compose file (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/tests/unit/composeEnvironment.test.ts | 46 ++++++++++++++++--- docker-compose.prod.yml | 34 +++++++++----- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/backend/tests/unit/composeEnvironment.test.ts b/backend/tests/unit/composeEnvironment.test.ts index fe0fb06..e8d3cd3 100644 --- a/backend/tests/unit/composeEnvironment.test.ts +++ b/backend/tests/unit/composeEnvironment.test.ts @@ -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>): 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 { +function environmentEntries( + source: string, + stackVariables: Readonly> +): Map { const entries = new Map(); // 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 { // 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 { 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7db6f41..27892a2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} -- 2.54.0