Skip to content
Runtime

Playground (demo mode)

Run a public, no-signup demo instance — one-click sign-in with published demo credentials, outbound/destructive endpoints blocked, and the whole workspace wiped + reseeded from a template on a timer.

Demo mode turns an instance into a public playground: visitors sign in with one click (no signup), poke at a fully seeded workspace, and everything they did is wiped on a timer. It powers the “try the live playground” funnel on the website, and works the same on any self-hosted deploy.

Set the env vars and (re)start the instance:

VarMeaning
DEMO_MODE1/true turns the playground on. Never set it on a real instance — it publishes the admin credentials by design.
SEED_TEMPLATESchema-template id (e.g. ecommerce, blog, crm) the workspace is reseeded from after every wipe.
DEMO_EMAILDemo-admin email shown on the sign-in screen. Default demo@backlex.com.
DEMO_PASSWORDDemo-admin password (public by design). Default playground.
DEMO_RESET_MINUTESMinutes between wipes. Default 60.

No manual provisioning is needed: on a brand-new database the first cron tick bootstraps the demo admin, applies SEED_TEMPLATE, and the instance is ready.

  • The public auth surface (GET /api/auth/providers) carries a demo: { email, password } field, so the sign-in screen shows a one-click Enter the playground button.
  • The admin shell shows a persistent banner (“shared demo — resets every hour”) so nobody mistakes it for a private instance.
  • The demo account is a normal instance admin: collections, items, flows, forms, dashboards, API keys, GraphQL, MCP — all real.

Writes to endpoints that could send outbound traffic, break the instance, or lock everyone out of the shared account return 403 (“This action is disabled in the playground”):

  • email / SMS / push config and sends (/api/admin/email-config, /api/admin/sms-config, /api/admin/push-config, /api/messaging/*)
  • auth config + SSO (/api/admin/auth, /api/admin/saml, /api/admin/ldap-config, /api/admin/platform-*)
  • auth hooks (/api/admin/auth-hooks, and the GraphQL/MCP twins through the service) — a send-email hook receives every end-user magic link and one-time code, and every playground visitor is an admin. Sync and auth hooks are also wiped on reset, so none outlives it
  • external-DB migrations (/api/admin/migrate) and raw SQL (/api/admin/db)
  • demo-account takeover (/api/auth/change-password, change-email, delete-user, two-factor)

Reads on all of those prefixes stay open so the admin pages render. The block list lives in apps/web/src/server/services/demo.ts (isDemoBlockedRequest). For abuse control beyond the guard, the usual knobs apply: API_RATE_LIMIT_* and USAGE_LIMIT_*.

resetDemoWorkspace (driven by the scheduler; also POST /api/admin/demo/reset for a manual wipe) converges the instance back to its seeded state:

  1. drops every managed collection’s physical table + all collection metadata, then every managed table (c_<12 hex>_<slug>) still on disk that no collection row points at — an orphan would otherwise survive every reset and make the template skip its collection for good,
  2. best-effort deletes stored file objects, then truncates every visitor-state system table (flows, webhooks, api keys, app users, forms, dashboards, settings, …) — keeping the scheduler’s __sweep__* watermarks,
  3. deletes every workspace except the default one and every user except the demo admin — recreating the admin with the published password if a visitor changed or deleted it,
  4. re-applies SEED_TEMPLATE (collections, sample rows, roles, dashboards). An apply that throws, or that skips any collection on the freshly wiped workspace, fails the reset — so it is retried (below) instead of leaving a partly seeded playground for the whole interval.

The last-reset timestamp is persisted in app_settings, so the cadence survives isolate restarts; each isolate re-checks it at most every 5 minutes. Visitors signed in when the wipe lands keep their cookie but the shared account’s state resets under them — by design.

The timestamp is claimed before the wipe so concurrent isolates back off mid-reset. If the reset then fails, maybeResetDemo hands the claim back with a DEMO_RETRY_BACKOFF_MS (5 min) delay rather than sitting on a half-wiped workspace for the rest of the interval — a broken playground self-heals minutes after the cause is fixed instead of at the next hour boundary.

  • The playground is just a normal deploy (Workers, Bun, Vercel, Netlify) with the env vars above — nothing else is special about it.
  • Link previews (Cloudflare only). A shared playground link unfurls on LinkedIn, Slack and X with a title, description and image, because the Worker adds Open Graph tags to the landing page when DEMO_MODE is on. That needs "/" in run_worker_first — wrangler.playground.toml has it — since Static Assets otherwise answers / before any code runs. The document keeps exactly the static shell’s headers, and an instance without DEMO_MODE gets its shell back unchanged, so the route is safe in any config. Other targets serve the plain shell, which carries no card.
  • Storage blobs whose metadata rows were wiped past the per-reset cleanup cap (1000 objects) are orphaned; point the playground at a dedicated bucket.
  • Keep the playground’s own database migrated. A dedicated deploy has its own D1/Postgres, and schema drift there is silent until a reset dies mid-way (a wiped workspace whose demo admin never gets its role back → an empty admin UI). migrate-d1.ts takes the database name from the --config file’s first [[d1_databases]] entry, so the deploy’s own wrangler config is enough: bun run packages/db/src/sqlite/migrate-d1.ts --remote --config=apps/web/wrangler.playground.toml. (wrangler d1 execute <name> resolves the name against the account, not the config — a stale D1_DATABASE_NAME override silently migrates a different database and still exits 0.)
  • Tests: apps/web/tests/settings/demo-mode.test.ts, and apps/web/tests/runtime/playground-share-card.test.ts for the link preview.