Feature/191 production demo mode interim #192

Merged
bermudalamb merged 4 commits from feature/191-production-demo-mode-interim into main 2026-08-26 09:17:22 -05:00
2 changed files with 76 additions and 9 deletions
+44 -4
View File
@@ -48,6 +48,10 @@ const DEPLOYMENTS = [
{ {
file: 'docker-compose.qa.yml', file: 'docker-compose.qa.yml',
demoMode: 'true', 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 // The entire safety property of QA: delivery is restricted to named
// recipients, so a run against a database full of test fixtures cannot // recipients, so a run against a database full of test fixtures cannot
// email a real customer. Asserted because the compose file claims it must // email a real customer. Asserted because the compose file claims it must
@@ -56,7 +60,23 @@ const DEPLOYMENTS = [
}, },
{ {
file: 'docker-compose.prod.yml', file: 'docker-compose.prod.yml',
demoMode: 'false', // 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.
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 // Deliberately unrestricted. Production has to be able to reach real
// customers, and it is the one environment where that is correct. // customers, and it is the one environment where that is correct.
requiresMailAllowlist: false requiresMailAllowlist: false
@@ -78,9 +98,29 @@ function resolveDefault(value: string): string {
return withDefault?.[1] ?? value; 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. // Only real environment entries — `- NAME=value` at an indented list position.
// A mention inside a comment cannot match, because a comment line starts with #. // 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>(); const entries = new Map<string, string>();
// The split pattern tolerates carriage returns. A checkout with CRLF line // The split pattern tolerates carriage returns. A checkout with CRLF line
// endings, which is every fresh clone on Windows, otherwise leaves a stray // endings, which is every fresh clone on Windows, otherwise leaves a stray
@@ -94,7 +134,7 @@ function environmentEntries(source: string): Map<string, string> {
// but RegExpExecArray cannot say so, and #101 made the compiler insist. // but RegExpExecArray cannot say so, and #101 made the compiler insist.
const [, name, value] = match ?? []; const [, name, value] = match ?? [];
if (name !== undefined && value !== undefined) { if (name !== undefined && value !== undefined) {
entries.set(name, resolveDefault(value.trim())); entries.set(name, resolveEntry(value.trim(), stackVariables));
} }
} }
return entries; return entries;
@@ -102,7 +142,7 @@ function environmentEntries(source: string): Map<string, string> {
describe.each(DEPLOYMENTS)('$file provides everything the app requires to boot', (deployment) => { describe.each(DEPLOYMENTS)('$file provides everything the app requires to boot', (deployment) => {
const compose = readFileSync(path.join(REPO_ROOT, deployment.file), 'utf8'); 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', () => { it('parsed some environment entries at all', () => {
// Guards the guard: a regex that matched nothing would make every // Guards the guard: a regex that matched nothing would make every
+32 -5
View File
@@ -58,6 +58,12 @@
# Required stack environment variables — all secrets, all must be set in # Required stack environment variables — all secrets, all must be set in
# Portainer for this stack: # 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. # DB_PASSWORD Postgres password for the `redefined` database.
# SMTP_USER Brevo SMTP login. # SMTP_USER Brevo SMTP login.
# SMTP_PASSWORD # SMTP_PASSWORD
@@ -104,15 +110,36 @@ services:
- PGPASSWORD=${DB_PASSWORD} - PGPASSWORD=${DB_PASSWORD}
- PGDATABASE=redefined - 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 #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 # Real payments. This is the difference between production and QA, and it
# is why the three PayPal secrets are required rather than optional — the # is why the three PayPal secrets are required rather than optional — the
# app refuses to start without them when this is false. # app refuses to start without them when this is false.
# #
# To bring the stack up before PayPal is configured, set this to `true` # Supplied by the stack, and with NO DEFAULT, deliberately.
# and the three PAYPAL_ lines can be removed. The full cart and checkout # `${DEMO_MODE:-false}` is the obvious thing to write and the wrong thing:
# flow then works end to end and NOBODY IS EVER CHARGED. That is a # a default decides whether the shop takes money on the operator's behalf,
# deliberate interim state and a quiet disaster if it is left on. # silently, whichever way it points.
- DEMO_MODE=false #
# 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_ENV=live
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
- PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET} - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}