2026-09-24

Demo mode as signed config, not a fork

A commerce platform without a free tier still needs a way for a prospect to try it. The lazy route is a separate demo deployment or a long-lived branch, both of which rot. I built demo mode as runtime configuration in the signed tenant document, the same one that carries entitlements. Flipping demoMode changes behaviour within the normal propagation window (about two minutes), with no platform deploy.

Safe by default

The flag has to fail in exactly one direction. If the document is missing, unverifiable or malformed, demoMode is false. A configuration failure must never be able to turn a paying store into a demo, and the reverse is acceptable: a demo that briefly behaves like a real store is far less damaging than a real store that stops taking payments.

The payload carries three related fields:

{ "demoMode": true, "demoExpiresAt": "2026-08-04T00:00:00Z", "demoSignupUrl": "https://example.com/start" }

The expiry drives a live countdown in the banner. The signup URL must be HTTPS or it is ignored. Both are also ignored unless demoMode is true.

Suppress at the egress layer

The interesting question is what a demo must not be able to do. The answer is a list of side effects that leave the system or abuse it:

Capability Behaviour in demo
Email, every path A transport wrapper intercepts each send, logs demo_email_simulated and returns success. Nothing reaches the mail provider
Payments Checkout completes in simulated mode. No real card entry. Payment webhooks return 403
Agent access (MCP and OAuth routes) Return 403, and admin shows the endpoint with a “connection not allowed” explanation
Media upload Rejected. Seed imagery stays
Catalogue Products, variants, types, attributes, categories and collections are view-only. Mutating verbs return 403
Custom script injection Creation and enabling blocked, even though demo entitlements otherwise match a paid tier
Admin-triggered builds Capped at 2 per demo, on top of the hourly rate limit
Server-side ad forwarding Suppressed

The email row is the pattern worth copying. Rather than adding if (demo) checks at every call site, the transport is wrapped, so every current and future code path that sends mail is covered by construction and reports success to its caller. The suppression sits at the boundary where side effects leave the system.

What demo mode does not do

It does not make the admin read-only. Checkout, order management and storefront serving stay available, because the invariants that protect real stores hold here too, and a prospect should be able to place a test order and fulfil it. Only catalogue data is frozen, because catalogue edits are what make a public demo drift into an ugly state. Everything else stays editable until reset.

Demo tenants are given paid-tier-equivalent entitlements so gated features are visible, since a demo that hides the product’s best parts is not a demo.

Hygiene for something public

The seed snapshot has a contract test

The demo data lives in the repository as a manifest plus a snapshot with stable demo_* IDs:

demo/
  manifest.json    snapshotVersion, schemaVersion, minimum entity counts
  snapshot.json    canonical commerce and content rows

The control plane wipes the tenant’s commerce and content tables and re-inserts the snapshot on provisioning and on reset. A CI test validates the manifest’s minimum counts against the snapshot, and the manifest records the schema version it was built for. So when a migration changes the schema and nobody updates the demo data, the build breaks instead of a prospect finding an empty store. Seed data is code and it needs a compiler.

Takeaways

← All posts