Repository navigation
ADR 0068 Public Demo Mode
Proposed (2026-10-29). Hosting, the reset interval and shared or isolated demo accounts are open decisions of the owner (see "Open questions").
The owner wants a public demo to link from the portfolio: visitors open it, try MowDeck with simulated mowers and leave. Nobody registers, nothing real is reachable.
What exists:
- The simulator implements the control interface (ADR 0012) with its own map, battery, RTK, camera test pattern, Bluetooth bridge, firmware and settings; Fleet's
MOWDECK_ALLOWED_CONNECTION_KINDScan offersimulatedonly. - Every service is built with
mowdeck_service_kit.create_app, which already adds response headers to every response (X-Source-Code, AGPL-3.0 section 13). - Third parties a normal installation talks to: the manufacturer cloud (linked accounts, cloud mowers, the camera over Agora), Bluetooth bridges on the LAN, Web Push services, APNs, ntfy, LLM providers (Vision), Open-Meteo (Scheduler), tile and WMS servers (Telemetry, the web app), GitHub and Apple (sign-in), Stripe (Billing), Matter controllers on the LAN.
- The theft alarm and tracking (ADR 0027, ADR 0058), personal access tokens (ADR 0064), passkeys, GitHub and Apple sign-in (ADR 0050 to ADR 0052) and the deploy maintenance window (ADR 0055).
Owner requirements (binding):
- No account creation at all: no registration, invitations, new users, password reset or e-mail. Visitors only enter the prepared demo account through one click, "Try the demo".
- Visitors may try everything on the simulated mowers (start, pause, dock, manual drive, schedules, zones and angles, maps, health, in-app notifications, heatmaps, settings of the demo data), but nothing that could harm the owner: no real manufacturer accounts or mowers, no outbound traffic to third parties, no API tokens, no changes to identity, security or billing, no access to the owner's real installation, rate limits against abuse, nothing persistent beyond the next reset.
- The demo is labelled everywhere and the labels cannot be dismissed: a banner on every page (phones and the app shells included), "Demo" in the page title, a demo badge on the sign-in entry, and
X-MowDeck-Demo: 1on API responses.
MOWDECK_DEMO_MODE=true is a setting of the service kit (ServiceSettings.demo_mode). With it create_app installs DemoModeMiddleware (mowdeck_service_kit.demo):
- every HTTP response carries
X-MowDeck-Demo: 1, errors and health checks included; - before any route runs, a mutating request (
POST,PUT,PATCH,DELETE) that the service'sDemoPolicynames is answered403with the problem typeurn:mowdeck:problem:demo-modeand a sentence for the visitor. Safe methods always pass.
A policy is either a list of refused paths (most services: the visitor may try everything but the few actions that reach out) or, with allow_only, a list of the only allowed mutations (Identity, Entitlements, Billing: a new endpoint stays closed until someone decides it is safe). The web app shows the problem as "Not available in the demo".
What each service refuses, and what it forces regardless of its variables:
| Service | Refused in demo mode | Forced in demo mode |
|---|---|---|
| Identity | everything but POST /demo/session, POST /demo/seed (seed secret), refresh, logout, the permission check and the setup tour: registration, password, passkey, GitHub and Apple sign-in, the app hand-off, token exchange, personal access tokens, widget credentials, password and passkey changes, step-up, logout everywhere, the security center's alert switch and ending sessions (ADR 0065; its overview, sessions and events stay readable), account, users, roles, API tokens, platform |
registration closed, passkeys, GitHub and Apple off; demo sign-ins remember no device and raise no new-login alert |
| Fleet | linking manufacturer accounts, adding, renaming, reconfiguring or removing mowers, the Bluetooth bridge link, every base station change (adding, renaming, removing, its firmware, a mower's base station) | allowed_connection_kinds = {simulated} |
| Device-Gateway | (no API) | only simulated connections are created (SimulatedOnlyError for any other kind), no cloud factory, no cloud discovery |
| Notifications | Web Push subscriptions, APNs devices, Live Activities, ntfy, the test message | no channel at all (the inbox only), no ntfy servers, no push photos |
| Vision | an own AI provider (set, test, remove), picture uploads | installation default on with the rule based fake/recover, users may set no base URL |
| Scheduler | the development weather endpoint | static weather (no Open-Meteo), no development endpoints, at most 30 schedules |
| Entitlements, Billing | every change | |
| Telemetry, Realtime, Planner | nothing; labelled only |
Commands, manual driving, spot runs, the camera test pattern, firmware updates of the simulator, area names, mowing defaults, sites and groups, schedules, angles, the inbox, the theft tracking of a simulated mower and the fake AI assistant all work: they only touch simulated mowers and the demo's own database.
Identity gets POST /api/v1/identity/demo/session: a session (the same tokens and refresh cookie as a password login, amr demo) of the prepared user MOWDECK_IDENTITY_DEMO_USERNAME (default demo), without credentials. It is 404 outside demo mode and before the seed, and has the per-address login limit (in Identity and in Traefik). The user has a random password nobody knows; password login is refused anyway. Every visitor shares this one account (see "Open questions").
A one-shot Compose service demo-seed (infra/compose/demo/seed.py, standard library only, in the identity image) prepares a fresh demo before the Device-Gateway starts:
-
POST /identity/demo/seedcreates the demo account and user (idempotent). -
POST /identity/demo/session, then the setup tour is marked done. - Fleet registers three simulated mowers in three gardens:
family(lawn with pond and apple tree, side strip, Bluetooth bridge),orchard(a large meadow with trees and beehives, front lawn, Bluetooth bridge) anddefault(two lawns, a firmware update on offer). The simulator got the optiongardenfor this (mowdeck_control.simulator.GARDENS). - The Scheduler gets a weekly schedule per mower with angle rotation, and the demo user's notification preferences are stored with their defaults (
PUT /notifications/preferences, once): Notifications fills the in-app inbox only for users who saved preferences or registered a delivery target, and a demo has no target, so without this the inbox stayed empty. -
POST /telemetry/demo/historysimulates the past days offline (mowdeck_control.history.simulated_history: one job per day over every area, from the dock back to the dock, with Wi-Fi falling off with the distance from the house, a varying mobile signal and RTK float near the first no-go zone) and feeds them through Telemetry's normal ingestion, so tracks, coverage, heatmaps and the health history exist from the first minute. The history goes in before any live state exists, which is why the Device-Gateway waits for the seed job.
Calls the policy refuses to visitors (registering mowers, the seed endpoints) carry the shared secret MOWDECK_DEMO_SEED_TOKEN in X-MowDeck-Demo-Seed. The seed job talks to the services inside the Compose network only; Traefik removes that header from every outside request, and the seed endpoints compare it in constant time and are 404 outside demo mode.
infra/compose/docker-compose.demo.yml on top of docker-compose.yml, as its own Compose project (mowdeck-demo) with its own database, volumes, network and secrets (infra/compose/demo/demo.env, from demo.env.example); it never shares a stack, database or secret with an installation with real mowers. It:
- sets
MOWDECK_DEMO_MODE=trueon every service and the forced values above; - adds two Traefik entry point middlewares:
demo-headers(labels every response, the web app included, and emptiesX-MowDeck-Demo-Seed) anddemo-ratelimit(per client address, default 1200 requests a minute, burst 600, the web app's files included); "Try the demo" also has Identity's login limit; - runs the seed job and the live simulation a little faster than real time (
DEMO_SIM_TIME_SCALE, default 3); - leaves map tiles, aerial photos, the street map, the camera relay, Agora, Matter and Billing off.
demo/reset.shrefusesCOMPOSE_PROFILES; - publishes only the gateway's HTTP entry point (
GATEWAY_HTTP_PORT): no HTTPS port, and no host port of NATS, Postgres or OpenFGA (amendment below).
infra/compose/demo/reset.sh deletes the demo project with its volumes and starts it again (down --volumes, up --build --wait); the seed job runs on every start. A systemd timer or cron entry runs it every DEMO_RESET_HOURS (default 6; docs/ops/demo.md). Nothing a visitor does survives a reset. The script only touches the project named in demo.env, refuses the project mowdeck and the placeholder secrets.
MOWDECK_WEB_DEMO_MODE and MOWDECK_WEB_DEMO_RESET_HOURS add demo: { resetHours } to /config.json. The app then shows:
- a band above every page ("Demo · Simulated mowers, shared with every visitor. Resets every 6 h.", short on phones) in the strip the page frame reserves at the top, with a hatched amber tag like a test rig's calibration tape and a link to the source code. It has no close button and is part of every page, so it also shows in the iOS and Android app shells, which load these pages;
- "Demo · " in front of every page title (kept there while pages change their title);
- on the sign-in page only the demo entry: a demo badge, what can be tried, the "Try the demo" button and the note that the demo is shared and reset; no password form, no other sign-in method, no link to registration;
- on the registration page a note instead of the form.
The labels in the web app are a courtesy; the enforcement is in the services.
- A demo is one environment variable per service plus an override file. A new mutating endpoint is refused in Identity, Entitlements and Billing until it is added to their allow lists; in the other services it is allowed until someone adds it to the policy, which a review of every new endpoint must consider (the table above and the guarantees below are the checklist).
- The seed secret is the one way past the policy. It only matters inside the demo's own network: Traefik strips it, and a demo has no real data to protect behind it.
- All visitors share one account: one visitor can pause another's mower or delete a schedule. The reset bounds the damage; the policy keeps the mowers themselves and the account intact.
- Unit tests cover the middleware, every service's policy and forced settings, the seed endpoints, the gardens and the history; the Playwright specs
demo.spec.ts(projectsdemo,demo-phone) run in the CI jobe2e-demoagainst the test stack with the demo override. - Nothing is deployed by this change; where and under which address the demo runs is the owner's decision.
A network audit of a local demo found three gaps; the decision above is unchanged, the stack is tightened:
-
IPv6 per /64. Per-address limits keyed the full IPv6 address, so one connection (a /64) had unlimited buckets. Every Traefik rate limit (Compose and Helm) now sets
sourceCriterion.ipStrategy.ipv6Subnet=64, and the in-process limiters (Identity's login throttle, Telemetry's public tracking limits) keymowdeck_service_kit.client_address.limiter_key: IPv4 unchanged, IPv6 its /64. The client address itself stays whole for audit logs and the security center. Telemetry's limiter no longer clears every counter when it holds 10,000 addresses; it forgets the least recently active ones. -
Host ports. The demo published NATS (client and monitor), Postgres and OpenFGA on host loopback and an HTTPS port no router used.
docker-compose.demo.ymlnow drops them (ports: !reset [], Traefikports: !override); the regular stack is unchanged. NATS had no authentication in any stack; authenticating the services to NATS (one credential per service) was a follow-up with its own ADR, done in ADR 0074 (2026-11-01): every service has its own user with least-privilege subject permissions, in the demo too. -
Override allow-list.
demo/reset.shcompared a deny-list of lines, which a new router, Traefik API flags,MOWDECK_CLIENT_IP_DEPTH, ports or an image swap got around. It now diffsdocker compose config --format jsonwith and withoutdemo/demo.override.yml(demo/override_guard.py, needspython3) and accepts only networks of existing services, new external networks, resource limits and logging.
A security review of the running demo found that the policy, which only looks at mutations, is not enough while every visitor is the same owner: every read endpoint answers with the full rights of the account. The demo now also guarantees:
-
Nobody sees another visitor's sign-in. The security center (ADR 0065) and the account's audit log (
GET /identity/accounts/{id}/audit, which the demo user may read throughaudit.viewand the API explorer makes one click away) only return rows of the caller's own login session (details.sessionequal to the token'ssid), and the audit log leaves out every address (ipis null). Other read endpoints were checked: the user list (one shared user,last_login_atonly), API tokens, passkeys and linked providers (nothing can be created), the notification inbox (demo sign-ins raise no new-login alert), Fleet's commands and drive sessions (the shared principal only), tracking links (metadata, the URL only to its creator) and Vision carry no address, user agent, session or token id of a visitor. -
Live updates for everybody. Realtime limits sockets per user, which in the demo would be one limit for all visitors. With
MOWDECK_DEMO_MODEit counts per login session instead;MOWDECK_REALTIME_MAX_CONNECTIONS_PER_ADDRESS(the shared client address rule) keeps one visitor from taking every socket, and the account limit is the total (docker-compose.demo.yml: 10 per session, 20 per address, 500 in all). - No base station changes, firmware and a mower's base station included (the table above).
-
No spoofed labels. Names people type for others (sites, groups, mowers, areas, zones, schedules, base stations, blade notes, planner template descriptions, and Identity's account, user, role, token and passkey names) are
mowdeck_contracts.v1.PlainText: control and format characters (bidirectional overrides, zero-width spaces) are removed before the length checks; the zero-width joiner and non-joiner stay for emoji and scripts that need them. This holds for every installation, not only the demo. -
Hardening headers on every service response (
mowdeck_service_kit.hardening, every installation):X-Content-Type-Options: nosniff, andCache-Control: no-storeon answers to authenticated requests unless the route sets its own caching (tiles, lexicons).
Checklist for a new endpoint, besides the refusal table: a GET endpoint that lists sessions, addresses, user agents or token ids must filter them to the caller's own login session in demo mode, since every visitor holds the demo user's rights.
A re-audit tightened three points (every installation unless noted):
- Realtime's per-address socket limit counts
limiter_keytoo (IPv6 per /64), like every other per-address limit. - A name left with nothing but whitespace and joiners (U+200D, U+200C) looks blank and counts as empty: refused where a name is required, and for an area name it clears the custom name like an empty one. A planner template name, the URL key of
PUT /planner/templates/{name}, is refused (422) when cleaning would change it, since a key cannot be rewritten silently; reading and deleting take any name. - The override allow-list (demo only) lets only the gateway (
traefik) join networks, only networks outside the demo, and under no alias that is a service name of the stack;deploy.resourcesis accepted with the emptydeploy.placementCompose renders along with it.
Follow-up: a reduced demo role. The cleaner fix is a demo user without the account-admin reads (audit.view, users.manage, billing.manage). OpenFGA derives them from owner and admin (can_view_audit: owner or admin or ...), and the demo user owns the account it was registered with, so it needs a separate owner nobody signs in as and the visitor as a member with a custom role at account level; the seed job, the tuples it writes, and the web app areas that follow the role would change with it. Until then the filters above close the gap.
The open question "shared or isolated accounts" is decided in ADR 0076: with DEMO_SANDBOXES (on by default in docker-compose.demo.yml) "Try the demo" gives every visitor an own sandbox account with its own simulated mowers, schedules and inbox, claimed from a small ready pool, capped (503 demo-full), limited per address, deleted after 30 minutes without a visit, at a logout or after the reset interval, and purged in every service through demo-only purge endpoints. The mowers of a sandbox nobody watches rest in the Device-Gateway (Realtime's presence.accounts). The policy, the labels, the seed secret, the reset and the hardening of this ADR stay; the shared demo user and its read filters remain the mode with DEMO_SANDBOXES=false, and the seed job then prepares it as before.
-
Hosting: which host and address (a small VPS, a separate VM; never the host of a real installation), TLS in front of Traefik (
GATEWAY_TRUSTED_PROXIES), and whether a public demo in Germany needs the imprint pages (WEB_LEGAL_ENABLED, filled/legal/*.md). - Reset interval: 6 hours by default; shorter resets limit vandalism, longer ones keep a visitor's session alive longer (a reset ends every session).
- Shared or isolated accounts (decided in ADR 0076: a sandbox per visitor): one shared demo account (this ADR) is simple and shows the multi-user live view, but visitors see each other's actions. An account per visitor would need a pool of seeded accounts or seeding on demand (about a second of simulated history per mower), plus a cap on concurrent accounts.
Mirrored from docs/adr/0068-public-demo-mode.md at commit fc91850a2b39cb822032acc284bb1e82241af0a8. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit fc91850a2b39cb822032acc284bb1e82241af0a8. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
- ADR 0073: Guided tour with coach marks
- ADR 0074: NATS authentication and per-service subject permissions
- ADR 0075: Chat control as an optional assistant add-on
- ADR 0076: Demo sandboxes, one account per visitor
- ADR 0077: Liveness, readiness and routing
- ADR 0078: Batched state ingestion
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Fuzzing and property-based tests
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Chat control (assistant add-on)
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Load testing
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Chat control
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)