Files
redefined-designs/docker-compose.prod.yml
synAdminandClaude Opus 5 9cc82002b8
Linting / lint (pull_request) Successful in 3m19s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m40s
feat(auth): groundwork for signing in with Google (#340)
Nothing a customer can see. The schema change and the configuration land on their own so the widest-reaching edit in the project can be reviewed for what it is rather than buried inside a feature.

The password hash becomes nullable. That is one line and it is not the work; the work is that every read of the column is now a question rather than a fact. Three places compared against it with bcrypt, and all three now ask first through one shared function.

That function exists because the alternative is worse than a wrong answer. bcrypt.compare throws on a null hash rather than returning false, so any call site that forgot the check would answer a sign-in attempt with a 500 instead of a refusal. On the login route that is also an oracle, because it would happen for exactly the accounts that have no password. One function rather than a null check repeated three times means the question is asked identically everywhere and a fourth site cannot forget to ask it.

Nothing writes a null yet. The first accounts without a password arrive with the sign-up path, which is why this is landed ahead of them.

The identities table is a table rather than columns on customers, because one customer may eventually hold more than one. Columns would make a second provider a migration and a third an embarrassment.

Its important column is the provider subject, and the comment on it is the whole security posture of the feature in one place: never the email. An email is a display value its owner can change and a provider may reassign; a subject is opaque and stable for the life of the account. Matching on the email would strand a customer who changed theirs and, far worse, hand their account to whoever inherited the old address. Unique across the provider and subject together, not the subject alone.

The down migration drops the table and deliberately does not restore the NOT NULL. Re-adding it fails outright once a passwordless customer exists, and a down migration that destroys accounts to satisfy a constraint is far worse than a column that is merely more permissive than it needs to be.

The redirect URI is derived from PUBLIC_URL, the same single source the WebAuthn Relying Party ID uses and for the same reason: Google compares it as an exact string and answers a mismatch with a message that says nothing about which half is wrong. Deriving it means the value is correct by construction anywhere the email links already are. The tests are mostly about what must not end up in it, since a trailing slash on PUBLIC_URL is an easy way to produce a URI that is one character from the registered one.

The config also reports whether it is enabled at all, so a developer without credentials gets a storefront that works and simply does not offer the button, rather than one that offers it and fails. Absent rather than disabled, the same choice made for a browser without WebAuthn.

Environment validation refuses to boot on one credential without the other, matching how the SMTP pair is handled. Half-configured is the case worth catching because the failure otherwise arrives at the moment a customer presses the button.

The QA compose file sets both to empty, and the comment there says why at length rather than leaving it to look like an oversight. Google refuses a redirect URI whose host is not under a domain whose ownership has been proved by DNS, and nobody can prove ownership of anything under bermudalamb.synology.me because Synology owns the registrable domain above it. That is the same wall #285 hit with Cloudflare. So QA cannot run this at all until #313 moves it to a subdomain of the real domain, at which point it is two stack variables and one console entry, with no code change either way.

Also corrects the record in #332, which lists account deletion as confirming with a password. It does not; the route takes none and the confirmation is a modal in the account page. Deletion needed no change here.

Verified: backend tsc clean for src and tests, 550 unit tests pass including new coverage of the config derivation, the null-hash comparison and the environment rules; lint clean apart from warnings that predate this branch. The integration suite needs a database this machine has no Docker for.

Closes #340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 08:18:51 -05:00

451 lines
22 KiB
YAML

# Production stack — the live storefront. See issue #118 for why this file is
# in the repository at all.
#
# It used to live only in Portainer's web editor, which meant no test could read
# it. `backend/tests/unit/composeEnvironment.test.ts` asserts that the deploying
# environment sets every name in ALWAYS_REQUIRED, and it could only ever check
# QA. Production was unguarded, and on 2026-08-23 it refused to boot because
# UPLOADS_DIR had no line here — while being set in Portainer's stack variables,
# where it does nothing. Committing the file is what lets the guard cover it.
#
# DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor — otherwise the
# file here and the file that actually runs drift apart again, which is the
# whole problem this is solving.
#
# Migrating production onto it is a one-time procedure with an order that
# matters, and it is written down rather than restated here:
#
# docs/ops/production-stack-cutover.md
#
# Routine deploys after that are README.md, "Promoting a reviewed change to
# production". The two are different operations and the runbook says which is
# which.
#
# THIS STACK DOES NOT BUILD. It runs the image tagged redefined-designs:latest,
# which has to already exist on the NAS before the stack starts. A first deploy,
# or a NAS that has pruned images, fails with "image not found" rather than
# quietly building one — see #146.
#
# That is deliberate. QA builds from this repository and is reviewed; production
# then runs the image that was reviewed, promoted by hand:
#
# docker tag redefined-designs:qa redefined-designs:latest
#
# Building here instead would look simpler and would ship something else. The
# Dockerfile copies package.json without package-lock.json and installs with
# `npm install`, so two builds of the same commit can resolve different
# transitive dependencies. "Same git ref" is therefore not "same image", and the
# reviewed bytes are the only thing that is.
#
# The promotion is load-bearing, not a convenience. Forgetting it means a
# redeploy that reuses the previous redefined-designs:latest and appears to
# succeed while running old code — the same failure `pull_policy: build` guards
# against in QA, arriving by a different route. State which image is being
# promoted as part of the deploy.
#
# Leave any Portainer option that re-pulls images turned OFF — there is no
# registry to pull this image from.
#
# WHY MOST VALUES ARE HARDCODED HERE RATHER THAN INTERPOLATED
#
# Portainer's stack variables are substituted into this file; they are not
# handed to the container. A variable set in Portainer with no line here never
# reaches the app, and the failure reads as "I set it and it says it is not
# set". Secrets are interpolated below, because they have a reason not to be in
# the repository — and so is DEMO_MODE, which is not a secret but is the one
# value that gets flipped without a code change, which is a reason of its own
# (#190). Everything else is written out, so there is one place to look and one
# thing that can be wrong.
#
# The stack environment variables this file reads. Each entry says whether it is
# required and when — there is no blanket rule, because three are unused while
# DEMO_MODE is `true`, three are optional, and DEMO_MODE and SMTP_FROM are not
# secrets at all:
#
# 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
# SMTP_FROM The From address customers see.
# ADMIN_GATE_SECRET The shared secret Nginx Proxy Manager injects as the
# X-Admin-Gate header on the gated location. Both sides
# must hold the same value or the admin API returns 403.
# See #63. Without it, /api/admin is protected only by
# the proxy — anything reaching the container directly
# can administer the store.
# PAYPAL_CLIENT_ID Live PayPal credentials. Required when DEMO_MODE is
# PAYPAL_CLIENT_SECRET `false`; the app refuses to start without them rather
# PAYPAL_WEBHOOK_ID than pretending it can take payments. Unused while
# DEMO_MODE is `true`, so flipping it to `false` without
# them crash-loops the container.
# BACKUP_PASSPHRASE Optional. Set it and the uploads archives are
# encrypted at rest; leave it empty and they are not.
# See docs/ops/backup-and-restore.md before setting it —
# an archive nobody can decrypt is not a backup.
# USPS_CLIENT_ID Optional. Leave unset to run without address
# USPS_CLIENT_SECRET validation; the app degrades gracefully rather than
# failing, so an empty value is a working configuration.
# ANTHROPIC_API_KEY Optional. Drafts a listing from a submitted photo
# (#223). Unset means submissions still arrive and wait
# undrafted, which is a working configuration for the
# same reason USPS is. The one credential here that
# spends money per call, and on a path anybody holding
# an upload link can trigger — put a spend limit on the
# key in the Anthropic console, because nothing in this
# repository can enforce one.
# ANTHROPIC_WORKSPACE_ID Required alongside the key above when that key is
# identity-linked. Without it every draft fails with a
# 400 naming the missing header (#271).
# INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the
# intake notification email (#224). Absent, the email
# still sends and carries no shortcuts.
# GOOGLE_CLIENT_ID Optional, and all-or-nothing with the secret below —
# GOOGLE_CLIENT_SECRET setting one without the other refuses to boot (#340).
# Both unset means Google sign-in is simply not offered.
# The redirect URI is derived from PUBLIC_URL and must
# match what is registered in the Google Auth Platform
# exactly, so changing the domain means updating that
# console too (#313).
# REMBG_URL Optional. The background-removal sidecar, e.g.
# http://rembg-syn:7000. Unset turns the feature off.
#
# The names above are what this file reads. A stack variable under any other
# name is substituted nowhere and never reaches the container, so reconciling
# them is a step of its own in the cutover runbook.
services:
redefined-designs:
# No `build:` — see the note at the top of this file. This tag is produced
# by promoting the image QA was reviewed against, not by rebuilding here.
image: redefined-designs:latest
container_name: redefined-designs-syn
environment:
- TZ=America/Chicago
- PORT=3000
# NODE_ENV is deliberately absent. The Dockerfile sets it to `production`,
# and that value gates the `secure` flag on the session cookie. Setting it
# to anything else here would silently serve session cookies over plain
# HTTP. Do not add a line for it.
- PGHOST=redefined-designs-db-syn
- PGPORT=5432
- PGUSER=redefined
- 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 #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.
#
# 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}
- PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID}
# Host, port and secure are not secrets and are pinned rather than
# inherited: the mailer's fallbacks are Gmail's (smtp.gmail.com, 465, TLS)
# and Brevo needs 587 with STARTTLS, which is why SMTP_SECURE is false.
# Getting these wrong fails at send time, not at boot.
- SMTP_HOST=smtp-relay.brevo.com
- SMTP_PORT=587
- SMTP_SECURE=false
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM=${SMTP_FROM}
# MAIL_ALLOWLIST is deliberately absent, and this is the one environment
# where that is correct. It restricts delivery to named recipients, which
# is what keeps QA from emailing real customers. Production has to be able
# to reach real customers, so it is unrestricted here on purpose. The
# boot-time warning about it is expected and should not be silenced.
# Not a secret, and hardcoded rather than interpolated so it cannot go
# missing: every link in a verification, password-reset, favorite-alert
# and cart-reminder email is built from it, and an unset value renders
# them all as "undefined".
- PUBLIC_URL=https://redefined-designs.bermudalamb.synology.me
- SITE_CURRENCY=USD
# Must match the right-hand side of the volume mapping below. Hardcoded
# for that reason — splitting it across two places is how they drift, and
# its absence is what stopped this stack booting on 2026-08-23.
- UPLOADS_DIR=/app/uploads
# Optional. Address validation is skipped when these are empty, rather
# than failing, so an unset pair is a working configuration.
- USPS_ENV=production
- USPS_CLIENT_ID=${USPS_CLIENT_ID}
- USPS_CLIENT_SECRET=${USPS_CLIENT_SECRET}
- ADMIN_GATE_SECRET=${ADMIN_GATE_SECRET}
# The origin uploaded images are fetched from (#103). Empty means this
# application serves them from its own origin, which works and is what
# every environment does today — so this is safe to leave unset while the
# hostname below does not exist yet.
#
# Present as a line even while empty, deliberately: a Portainer stack
# variable with no line here is substituted into this file and never
# reaches the container, which is exactly how UPLOADS_DIR went missing.
#
# Setting it needs an Nginx Proxy Manager host for the name, pointing at
# this same container, and a certificate that covers it. Until then the
# server warns at boot that the defence is off rather than staying silent.
- UPLOADS_BASE_URL=${UPLOADS_BASE_URL:-}
# Drafts a listing from a submitted photo (#223).
#
# Absent is a working configuration, deliberately. A submission still
# arrives, keeps its photos and waits in the queue undrafted — losing
# somebody's consignment to an expired key would be far worse than an
# item arriving without its description written, and the photos may be
# the only copy of an item no longer in the sender's hands.
#
# The one credential in this stack that spends money per call, on a path
# anybody holding an upload link can trigger. Set a spend limit on the
# key in the Anthropic console — nothing in this repository can enforce
# one, and #227 is the submission ceiling that bounds the volume rather
# than the bill.
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# Required alongside the key when that key is identity-linked. Such a key
# is refused with a 400 unless the request names its workspace, and a
# key's shape does not say which kind it is (#271). Optional: ordinary
# keys need no workspace.
- ANTHROPIC_WORKSPACE_ID=${ANTHROPIC_WORKSPACE_ID:-}
# Optional. The background-removal sidecar (#281).
# See docs/ops/image-background-removal-stack.md.
- REMBG_URL=${REMBG_URL:-}
# Brevo's Marketing Automation key (#56), from
# https://app.brevo.com/automation/parameters. Optional: unset means the
# tracker is never loaded and no browsing is reported to anyone.
#
# Not a secret — it ships to the browser by design — but it is
# per-environment, and this is the only place production's is named. A
# key here is still not sufficient to track anybody: the script loads
# only for a signed-in customer whose stored consent wording covers
# analytics. See the note on MARKETING_CONSENT_TEXT in backend/src/utils.ts.
- BREVO_TRACKER_KEY=${BREVO_TRACKER_KEY:-}
# Signs the regenerate and discard links in the intake notification email
# (#224). Optional: absent, the notification still sends and links to the
# review queue without shortcuts. Rotating it revokes every outstanding
# link, which is how a leaked one is dealt with.
- INTAKE_ACTION_SECRET=${INTAKE_ACTION_SECRET:-}
# Both or neither. envValidation refuses to start on one without the
# other, because the failure would otherwise arrive at the moment a
# customer presses the button (#340).
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
volumes:
# Production's own uploads directory. QA writes to
# /volume1/configs/redefined-designs-qa/uploads; sharing this one would
# let a QA teardown delete real product images.
- /volume1/configs/redefined-designs/uploads:/app/uploads
ports:
# 32750, not QA's 32751.
#
# An unqualified host port binds to 0.0.0.0, so this answers directly on
# http://<nas-ip>:32750 from anywhere on the LAN, bypassing Nginx Proxy
# Manager and its TLS. The admin gate still fails closed — a direct
# request arrives without the X-Admin-Gate header — but the storefront,
# the customer API, login and registration are all reachable in the clear.
#
# That is #117, and the fix is not a loopback binding: NPM runs as its own
# container, so 127.0.0.1 would stop the proxy reaching this at all. The
# fix is a shared external network with this block removed entirely, which
# also requires repointing the proxy host entry at the container name and
# port 3000. Left as-is here so this file matches what is deployed today;
# changing it is #117's job, not this file's.
- 32750:3000
depends_on:
redefined-designs-db-syn:
condition: service_healthy
# Unlike QA's `no`: production is meant to come back after a NAS reboot.
restart: unless-stopped
# Docker's default json-file driver has no size cap. POST /api/client-errors
# is unauthenticated, so an unrotated log is a disk-filling vector on its
# own. QA has carried these options for a while; production could not,
# because this file did not exist.
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redefined-designs-db-syn:
image: postgres:16
container_name: redefined-designs-db-syn
environment:
- POSTGRES_USER=redefined
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=redefined
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
# THE REAL DATA. Distinct from QA's
# /volume1/configs/redefined-designs-qa/postgres. Never point a QA stack
# at this path.
- /volume1/configs/redefined-designs/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U redefined -d redefined"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
# ---------------------------------------------------------------------------
# Backups (#147)
#
# Two services rather than one, because they are different jobs on different
# cadences. The database is small, changes constantly, and wants a logical
# dump. Uploads are large, append-mostly, and want an archive. Forcing both
# through one tool serves one of them badly.
#
# WHAT THESE DO NOT COVER, and it matters:
#
# They run while the stack runs, so they cannot protect the stack's own
# teardown. Deleting the Portainer stack deletes these containers along with
# everything else. The manual pg_dump in README's deploy steps therefore
# stays exactly where it is — a routine regime and a snapshot taken before a
# risky operation are different jobs, and neither replaces the other.
#
# And they write to the same volume as the data they protect. That survives a
# bad migration, a dropped table, a bad deploy and a stack deletion. It does
# not survive the disk. Getting a copy off /volume1 is a Synology-side job —
# Hyper Backup to another volume, an external disk, or offsite — and it is
# what turns this from a convenience into a guarantee. See
# docs/ops/backup-and-restore.md.
# ---------------------------------------------------------------------------
redefined-designs-db-backup-syn:
# Pinned to 16 to match the server. pg_dump refuses to dump a server newer
# than itself, so a floating tag here is a backup that stops working on the
# day Postgres is upgraded — silently, since nothing reads the dumps until
# they are needed.
image: prodrigestivill/postgres-backup-local:16
container_name: redefined-designs-db-backup-syn
environment:
- TZ=America/Chicago
- POSTGRES_HOST=redefined-designs-db-syn
- POSTGRES_PORT=5432
- POSTGRES_DB=redefined
- POSTGRES_USER=redefined
- POSTGRES_PASSWORD=${DB_PASSWORD}
# Daily at 03:00. Late enough that a deploy is unlikely to be in flight,
# and pg_dump takes a consistent snapshot anyway, so a dump running while
# customers are shopping is fine.
- SCHEDULE=@daily
- BACKUP_KEEP_DAYS=7
- BACKUP_KEEP_WEEKS=4
- BACKUP_KEEP_MONTHS=6
# --clean --if-exists so the dump can be restored over an existing
# database without hand-dropping it first, which is the state a real
# restore happens in.
- POSTGRES_EXTRA_OPTS=--clean --if-exists
volumes:
- /volume1/configs/redefined-designs/backups/postgres:/backups
depends_on:
redefined-designs-db-syn:
# The dumper is a client and needs a server accepting connections. This
# is the constraint that shaped the design: without it the first run
# after a NAS reboot races Postgres coming up.
condition: service_healthy
healthcheck:
# Unhealthy when nothing has been written inside the window. A backup
# regime that stopped a month ago is indistinguishable from a working one
# until a restore is attempted, and this is the cheapest thing that tells
# them apart. It shows in Portainer beside the app rather than somewhere
# separate to remember to look.
#
# 1560 minutes is 26 hours: the daily interval plus two hours of grace, so
# a dump that runs a little late is not reported as a failure.
test: ["CMD-SHELL", "find /backups -name '*.sql.gz' -mmin -1560 | grep -q ."]
interval: 1h
timeout: 30s
retries: 3
# Nothing exists until the first scheduled run, so without this the
# container reports unhealthy for its first day on every fresh deploy.
start_period: 25h
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redefined-designs-uploads-backup-syn:
image: offen/docker-volume-backup:v2
container_name: redefined-designs-uploads-backup-syn
environment:
- TZ=America/Chicago
# Weekly, not daily. Uploads are append-mostly and much larger than the
# database, so a daily full archive would mostly be copies of itself.
- BACKUP_CRON_EXPRESSION=0 4 * * 0
- BACKUP_FILENAME=uploads-%Y-%m-%dT%H-%M-%S.tar.gz
- BACKUP_ARCHIVE=/archive
# Eight weeks. Shorter than the database's tail because each archive is
# far bigger, and an image that was deleted two months ago is not
# something anyone is restoring.
- BACKUP_RETENTION_DAYS=56
- BACKUP_PRUNING_PREFIX=uploads-
# Optional. An empty value means no encryption, which is the default.
- GPG_PASSPHRASE=${BACKUP_PASSPHRASE}
volumes:
# Read-only. A backup process with write access to the thing it is backing
# up is a way to lose both at once.
- /volume1/configs/redefined-designs/uploads:/backup/uploads:ro
- /volume1/configs/redefined-designs/backups/uploads:/archive
healthcheck:
# Nine days: the weekly interval plus two days of grace.
test: ["CMD-SHELL", "find /archive -name 'uploads-*' -mmin -12960 | grep -q ."]
interval: 6h
timeout: 30s
retries: 3
start_period: 8d
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"