Repository navigation
ADR 0072 Api Explorer In The Web App
Accepted (2026-10-30). Amends the follow-up of ADR 0064 ("try it out" in the web app).
The owner wants the API documentation linked with the public demo (ADR 0068) so that visitors can try the API, not only read about it.
What exists:
- The API reference is plain Markdown generated from the services' OpenAPI 3.1 documents (
apps/web/openapi, ADR 0064); it stays the canonical reference, works offline, in the wiki mirror and on GitHub. ADR 0064 named "try it out in the web app" as a follow-up that would reuse the same documents. - In the demo a visitor's "Try the demo" session already holds a normal 10 minute access token of the shared demo user. Every mutation reaches only simulated mowers or is refused with 403
demo-mode; the token exchange, personal access tokens and widget credentials are refused; Traefik'sdemo-ratelimitlimits every client address; the demo resets every few hours with its own database and secrets. - The web app's CSP is strict:
default-src 'self', scripts by hash, no inline styles except hashedstyle=""attributes, no third party (scripts/csp-hashes.mjs). -
mowdeck-service-kitgave every service FastAPI's Swagger UI at/api/v1/docs, which loadsswagger-ui-distfromcdn.jsdelivr.net. The gateway never routed it (Traefik sends/api/v1/<service>to a service, and the page was at/api/v1/docs), so it was reachable only on a service's own port, but it contradicted "nothing from a CDN" (ADR 0064 forces, docs/dev/web-app.md).
Forces:
- No new credential: the explorer must act with the session the visitor already has, never store it, and never need the token exchange the demo refuses.
- No third party: nothing from a CDN, no proxy, no telemetry, no fonts or AI features of the viewer's vendor; a missed setting must not leak anything either.
- The app's CSP must not be weakened for every page because one page embeds a viewer.
- Off everywhere except where it is wanted: on a real installation "try it" changes real data.
The viewer is Scalar (@scalar/api-reference, MIT), pinned to an exact version in apps/web/package.json (save-exact, the lockfile holds its integrity, Dependabot bumps it). Its standalone browser bundle is copied from node_modules at build time by build-tools/api-explorer.mjs into build/_explorer/ with the frame page (api-explorer/index.html), its glue (explorer.js, frame.js, explorer.css), the services' OpenAPI documents with operations under /api/v1 (Fleet first; Realtime has only its WebSocket) and Scalar's licence. The build refuses a range or a mismatch between the pinned and the installed version, and the page carries the SHA-384 integrity of its scripts and stylesheet. Nothing is checked in and nothing loads from a CDN. pnpm dev serves the same files through a Vite plugin.
The web app's page /api-explorer (route group (app), signed in) shows a short note, links to the scripting guide and the reference, and embeds the frame /_explorer/index.html. The frame is its own document, so it has its own CSP and the app's policy stays exactly as it was. build/_explorer is written after the app's CSP is computed and outside SvelteKit's build, so it neither widens the app's hashes nor ends up in the service worker's precache.
Considered: Swagger UI and Redoc (Swagger UI's bundle is larger and needs a proxy or its own CSP exceptions too; Redoc has no "try it"), Scalar mounted directly inside the SPA (its runtime <style> elements would need style-src 'unsafe-inline' for the whole app, or a relaxed policy that stays in effect after leaving the page), a full-page explorer outside the app (it would need its own way to a token: a second refresh flow against the single-use refresh cookie of ADR 0021).
#lib/api-explorer/bridge.ts (page) and api-explorer/frame.js (frame) talk with postMessage, to and from this origin only: the frame posts mowdeck-explorer-ready; the page answers with mowdeck-explorer-token and the session's current access token, and sends every renewed token (the session refreshes 60 s before expiry) and null on logout. The page accepts messages only from the frame's window, the frame only from its parent, and the frame accepts only a JWT-shaped string. The frame adds a bearer security scheme to every document, sets this origin as the only server and passes the token to Scalar (authentication, persistAuth: false), so "try it" calls /api/v1 on the same origin: no CORS, no proxy. The token never leaves memory.
scalarConfiguration in api-explorer/frame.js (unit tested): withDefaultFonts: false (no fonts.scalar.com), no proxyUrl (never proxy.scalar.com), telemetry: false, agent: { disabled: true }, mcp: { disabled: true }, hideClientButton: true and showDeveloperTools: 'never' (both lead to scalar.com), every externalUrls entry (dashboard, registry, proxy, API) pointed at this origin's /_explorer/offline (404), persistAuth: false. Fonts are MowDeck's IBM Plex when the browser has them, the system fonts otherwise. Scalar's "Generate SDK" button appears only when the page runs on Amended 2026-10-31: Scalar treats every local URL as local, localhost; it would open the dashboard address above.127.0.0.1 and LAN addresses included, and there the button opened a dialog that uploads the document to scalar.com and links to scalar.com and x.com. Scalar 1.75 has no option to turn it off; it hides it when the document lists its own SDKs. prepareDocument therefore adds info.x-scalar-sdk-installation with one entry ("no generated SDK; use the session here and a personal access token in scripts"), so the button and its dialog never appear on any host (unit test and demo e2e spec). The frame's CSP is the backstop: connect-src, img-src, font-src and script-src are 'self', so a missed setting cannot reach a third party. The demo e2e spec asserts that no request of the page or the frame leaves the origin.
nginx/security-headers-explorer.conf, only for /_explorer/, differs from the app's in two points:
-
style-src 'self' 'unsafe-inline': Scalar injects its stylesheet and its theme as<style>elements at runtime. The stylesheet element would take a nonce (meta[property=csp-nonce]), but the theme element is rendered by Vue without one, and build-time hashes would break with every Scalar release. Scripts stay'self'only: no inline script, noeval, no other host. -
frame-ancestors 'self'(andX-Frame-Options: SAMEORIGIN): the app's page embeds it; nobody else may.
frame-src 'none', form-action 'none', base-uri 'none', object-src 'none'. The app's CSP (frame-ancestors 'none', no 'unsafe-inline') is untouched; default-src 'self' already allows the same-origin frame.
A runtime switch of the web image like the others (nginx/40-mowdeck-runtime-config.sh): apiExplorer in /config.json and /tmp/mowdeck/explorer.conf, which the /_explorer/ location includes. Off (the default) the page /api-explorer is a 404, nothing links to it, and nginx answers 404 for every file of the frame, the Scalar bundle included. docker-compose.demo.yml turns it on; Compose (WEB_API_EXPLORER) keeps it off everywhere else. On a real installation it can be turned on, and then "try it" acts with the signed-in user's full session and rights; the page says so.
In the demo the banner links it ("API live testen" / "Try the API live"), the sign-in page lists it among what can be tried, the API access settings link it, and the page's note says that every call reaches simulated mowers, that refused actions answer 403 demo-mode and when the demo resets. The scripting guide and the reference index point to /api-explorer of a demo installation.
create_app sets docs_url=None (and keeps redoc_url=None). /api/v1/openapi.json stays: the export (uv run poe openapi) reads app.openapi(), which is unchanged.
- Visitors of the demo can try every endpoint with the session they already have; nothing new to issue, revoke or rate limit, and the demo's policy and Traefik's limit apply as to the app.
- The web image is about 4.6 MB (1.3 MB compressed) larger, served only when the explorer is on, never precached by the service worker.
- A Scalar update is a Dependabot pull request; the frame's CSP and the privacy settings are re-checked by the unit tests (
api-explorer/frame.test.ts,build-tools/api-explorer.test.ts) and the demo e2e spec (tests/e2e-web/tests/api-explorer-demo.spec.ts: renders, "try it" GET of the simulated mowers, 403demo-modefor a refused mutation with the same credential, no third-party request, both CSPs).api-explorer-off-desktop.spec.tschecks that a regular stack serves none of it. - The Markdown reference stays canonical; the explorer is a view of the same documents.
- The demo's public address is not decided yet (ADR 0068); the docs name the path
/api-explorerof a demo installation instead of a host.
Mirrored from docs/adr/0072-api-explorer-in-the-web-app.md at commit 0b51164ccae892daef8e7473da86ee072409d2f4. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 0b51164ccae892daef8e7473da86ee072409d2f4. 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
-
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
- Source code offer (AGPL-3.0 section 13)
- Public demo
- 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)
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)