gridmason docs / Dashboard

The Dashboard

The Gridmason Dashboard is the platform's reference application. It is three things at once: the end-to-end proof that the whole platform works, the public showcase, and the target you point your own widgets at while you build them. It is also genuinely deployable — a small team can run it as their own dashboard, not just look at it.

It is a single-page app built in React and TypeScript with Vite, consuming @gridmason/core (the engine) and the @gridmason/* packages exactly the way any other host would. Nothing about React reaches the widget contract: core stays framework-agnostic, and custom-element widgets written in any framework mount unchanged.

What it is

The one invariant

The dashboard has no special-case page components. Every route does the same thing: resolve a { pageType, entityId? } and hand it to a single generic canvas host, which mounts core's <gm-page-canvas>. A fully locked page, a free-form dashboard, and a typed record-detail page are all the same component with different data. You add new page types and widgets as data and registrations, never as new page components. The route model is small:

Path Resolves to
/ { pageType: "dashboards.home" }
/p/:pageType { pageType }
/p/:pageType/:entityId { pageType, entityId }

The four demo page types

The app ships four page types that together prove the invariant — one generic canvas covers every governance posture:

  • dashboards.home — a free canvas the user arranges.
  • demo.record-detail — a typed record-ref context page with a locked header slot (the same component, driven by an entity id).
  • demo.locked — a fully locked page (allow_user_customization: false, every slot locked and immovable).
  • demo.full-canvas — a single maximized locked widget, which exists specifically to show the no-special-case rule holds even at that extreme.

The first-party demo widgets

Five widgets ship with the app. They exercise the whole widget ABI and double as worked examples — each is a small, framework-agnostic custom element themed only through CSS custom properties:

  • Clock — the minimal static-props shape.
  • Markdown — static props plus a safe, escape-first renderer.
  • Record summary — a context-consuming widget (typed record-ref).
  • Chart — JSON-schema-validated props with themed SVG marks.
  • Crasher — a deliberate throw-on-mount that proves the per-widget error boundary catches a failing widget without taking down the page.

The governance demo

The app makes the three-level layout resolution visible: publish an "organization layout" with locks, override it as a user, then reset. The reset removes only the user's override — the organization default is never mutated (copy-on-write). This is the governance story you would otherwise have to take on faith.

The live demo

A public build runs at https://gridmason.dev/demo/. It is the static-demo build (described below): the same app, the same four page types, and the same five first-party widgets, running fully serverless. What differs from a server-backed run is only the backend seam — layout persistence and governance use localStorage instead of the demo API, and login is a fixed baked-in demo user instead of the stub-login endpoint. The app above that seam, and everything you see rendered, is identical.

Status note. The current build is Phase A ("static boot"): every route mounts core's <gm-page-canvas> and renders a demo page type from a local import map, the stand-in for federated boot. Resolving and verifying widget remotes from a live registry is Phase B and is not wired in yet. Where this guide describes the federated/registry path, it is describing the design the Phase-B build realizes, and says so.

Running it

You need Node >= 22.12.

Dev run

npm install
npm run dev            # Vite dev server at http://localhost:5173

That is enough to see the app, the four page types, and the first-party widgets. Layout persistence and login only do something real once you also run the demo API (below); without it the app still boots.

Useful commands:

Command What it does
npm run dev Start the Vite dev server.
npm run build Type-check (tsc --noEmit) then build the static bundle to dist/.
npm run preview Serve the built dist/ bundle (port 4173).
npm run typecheck Type-check only.
npm run e2e Run the Playwright suite (run npm run build first — it serves the built bundle).

Docker deploy

The dashboard is a static single-page app, so deploying it means serving the built bundle with an SPA fallback and, ideally, the production security headers. The Dockerfile does both: it builds the bundle and serves it behind nginx. The container listens on port 80 and emits the enforced production Content-Security-Policy from docker/nginx.conf.

docker build -t gridmason-dashboard .
docker run -p 8080:80 gridmason-dashboard
# → http://localhost:8080

Static host (raw dist/)

For any static host or CDN, build the bundle and serve dist/:

npm run build      # → dist/

Two things the host must provide:

  • SPA fallback. Client-routed paths like /p/:pageType/:entityId must fall back to index.html, exactly as docker/nginx.conf's try_files … /index.html does. Configure your host's equivalent rewrite.
  • Security headers. docker/nginx.conf is the reference for the Content-Security-Policy (including frame-ancestors) the app expects. A host that can set response headers should serve the same policy; one that cannot can render most of it as a <meta> tag, with the caveats in the CSP guide.

Demo API config knobs

server/ holds the demo API — the reference persistence backend the dashboard's reference adapter talks to. It is a small node:http service (no web framework) that provides a layout key-value store keyed (scope|user, pageType, entityId?) → LayoutDoc, config loading (single-tenant users and enablement gates), and a stub login (config-file users, no real auth/SSO) that gates every route under /api/layouts and /api/auth/me.

Run it alongside the app:

npm run api:dev        # tsx watch, reloads on change
npm run api:start      # run once

It is configured entirely through environment variables:

Variable Default Controls
PORT 8787 listen port
GRIDMASON_DEMO_CONFIG server/config/demo-config.json config file (users + gates)
GRIDMASON_LAYOUT_STORE server/.data/layouts.json layout persistence file
GRIDMASON_GOVERNANCE_STORE server/.data/governance.json org-publication store
GRIDMASON_SIDELOAD_STORE server/.data/sideload.json acknowledged-sideload registrations

A malformed config fails loudly at startup rather than booting into a bad state.

Static demo build

npm run build:static-demo produces a fully serverless bundle — the same app and the same first-party widgets, but with no demo API. It is the build behind the public gridmason.dev/demo site, where there is no server to run.

npm run build:static-demo                 # → dist/  (serverless, base "/")
BASE_PATH=/demo/ npm run build:static-demo # → dist/  for subpath hosting at /demo

Everything it swaps in goes through the one sanctioned adapter seam (src/adapters/backend.ts); the app above it is unchanged:

  • Layout persistence → localStorage, keyed exactly as the API store is, so copy-on-write is preserved: an edit forks the user's override, Save persists it, reload restores it, and Reset removes only the override. Governance is localStorage-backed the same way.
  • Login → a fixed demo user baked from static JSON (which also carries the demo's role/gate posture) — no stub-login endpoint.
  • Widgets → the first-party demo widgets from the local import map. Sideload stays off and federated boot is inert, so the build makes zero network calls to any API.

Because the static-demo flag is a build-time constant, the unused fetch-based backend is tree-shaken out entirely. BASE_PATH sets both Vite's asset base and the client router's base, so assets and routes both resolve under a subpath like /demo. The build also injects the production CSP as a <meta> tag (see the security section) and emits a 404.html copy of the app shell so a header-less host like GitHub Pages serves client-routed deep links.

Sideload modes

The production path is registry-signed remotes only. Sideload is how a dashboard loads a widget that did not come through the signed registry. It is a host-configurable policy: Gridmason does not dictate your risk posture, it makes every posture explicit and off by default. There are three modes:

Mode What loads CSP handling
off (default) registry-signed remotes only production CSP, never relaxed
dev + local dev-server remotes, per-session allowlist, nothing persisted a dev build adds the localhost origin to script-src, only while the dev gate is on
acknowledged + persistent, owner-acknowledged remotes registered by URL (never inline or base64 code) each acknowledged origin is added to script-src by explicit owner action, recorded in config

Why default-off, and what "off" guarantees

With no mode set, off means off, and it blocks at two independent switches that both default off:

  • No remote enters the import map. With the client posture off, the sideload provider never fetches, resolves, or installs a registration — nothing an operator (or a compromised registration store) added can reach the render path.
  • No origin enters script-src. With the server config posture off, the authorized script-src additions are empty. The production CSP is never relaxed by default.

In a single-origin deployment you set both switches to the same value.

Dev sideload — the widget-author loop

dev mode is the loop you use while building a widget. gridmason dev (from @gridmason/cli) serves your widget locally; the dashboard hot-loads it; and gridmason lint runs the same automated checks a registry review runs. To enable it, start the dev server with GRIDMASON_DEV_SIDELOAD=1 (which delivers the dev-only script-src relaxation) and bake the client posture with GRIDMASON_SIDELOAD_MODE=dev. Even then, the Add-widget picker still requires an in-session acknowledgement before it will admit a remote. Dev sideload ships in development builds only — a production build drops it entirely.

What works end to end today, verified in a real browser against real gridmason dev: register the origin, hot-load the widget, see its distinct badge, mount it through the shared canvas + SDK path, and get live hot-reload on re-serve. The allowlist is per session — a page reload clears it.

Two honest limits of the dev loop:

  • Hot-reload remounts, it does not swap code in place. A custom-element tag can be defined only once per document, so re-importing a fresh entry re-runs customElements.define as a no-op and the old class stays registered. The dashboard does a scoped remount instead: it re-runs the widget's mount lifecycle, which picks up anything the widget re-reads on mount (data, content it fetches), but a change to the widget's own code is not reflected live. For a code change, restart the mount from the picker or reload the page.
  • Framework specifiers are not resolved for you. The dev server injects a @gridmason/* import map so a sideloaded widget's bare @gridmason/sdk / @gridmason/protocol imports resolve to the dashboard's own pinned copies. It does not map framework imports (react, vue). So a vanilla scaffold widget (SDK + protocol, no framework import) loads end to end today; a React/Vue one still needs its framework resolved, which is a later enhancement.

Acknowledged sideload — running your own unreviewed remotes

acknowledged mode is for operators who deliberately choose to run their own remotes in a persistent deployment. Set sideload.mode to acknowledged in the server config and bake the client posture with GRIDMASON_SIDELOAD_MODE=acknowledged. An owner then registers each remote by URL; registration pins the entry's content hash and records who acknowledged the risk. The pin is verified before the module runs, and a mismatch refuses the load.

Common to both, and the honesty caveat

Both dev and acknowledged are unlocked only by an explicit, disclaimed owner acknowledgement — you accept the risk of unreviewed code. Every sideloaded widget is marked distinctly in the UI, with a badge on its card and on its picker entry. And the production CSP is never silently relaxed: every sideload origin that appears in script-src is visible in the deployment's config.

Phase-A caveat (load-bearing). There is no signed, logged verification chain yet — that is the Phase-B Service-Worker path. Phase A ships hash-pinning at registration, but hash-pinning ties a remote to bytes an operator recorded; it does not prove those bytes were reviewed, and it does not close the time-of-check/time-of-use window. In Phase A the only real safeguard is your own review, so run only widgets you built or reviewed yourself.

Security model in practice

The dashboard is the reference for how a Gridmason host should be hardened. If you are building your own host, study these patterns here.

Service Worker: buffer-verify-serve

A federated widget's module is never fetched-then-evaled. The shell owns a Service Worker that intercepts every remote fetch and verifies it by exact URL and content hash against the specific registry's signed release document that listed it. Trust is bound per URL, not per origin, so two registries sharing a CDN host cannot cross-contaminate. The SW buffers each artifact fully, verifies the hash, and only then serves it — a tampered artifact is refused as a network error, so the import() never runs and the widget falls to its error boundary. A fetched URL that no release document claims is refused outright.

The SW lifecycle is itself part of the trust chain: the shell assembles the import map only after the SW controls the page. If the SW is unavailable (unsupported browser, disabled storage), the shell fails closed to shell-bundled content only — no registry or sideloaded remote loads without the verifying SW in front of it.

This is the Phase-B verification path. The SW verification core is already exercised in the exit demo and unit tests; what remains for Phase B is resolving a real release document from a running registry rather than handing the enforcement table in from a test.

CSP: strict, self-only, and it never widens for a widget

The enforced production policy is defined once in code (buildProductionCspHeader), and every delivery channel renders that same policy. The strict base is self-only:

default-src 'self';
script-src 'self' <registry-cdn origins> <acknowledged-sideload origins>;
connect-src 'self' <registry-cdn origins>;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
worker-src 'self';
manifest-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
frame-src 'none';
form-action 'self'

Key properties to carry into your own host:

  • script-src carries no 'unsafe-inline' or 'unsafe-eval' — the production bundle ships neither. It grows only by trusted registry-CDN origins a deployment federates and acknowledged-sideload origins, each added by explicit config.
  • connect-src carries no third-party host. A net:<host> widget cannot open a browser connection to its host — that call is proxied through the same-origin scoped-fetch endpoint (POST /api/scoped-fetch), which re-checks the widget's declared host allowlist server-side. Because no widget capability can widen connect-src, the policy never has to change for a widget.
  • No iframes. The app is neither embedded (frame-ancestors 'none') nor embeds anything (frame-src 'none'). Widgets are same-document custom elements; isolation is reviewed signed code plus a capability-scoped SDK plus CSP, not an iframe sandbox.

The checked-in nginx string is pinned identical to the builder's output by a test, so it cannot drift. vite preview serves the same policy report-only, and the e2e suite drives every demo flow asserting zero violations. On a header-less host, the static-demo build injects the same policy as a <meta> tag, with one documented delta: frame-ancestors is omitted because browsers ignore it in a <meta> tag — pair it with X-Frame-Options: DENY to keep the no-framing guarantee.

Per-instance tokens

Session auth and per-remote identity ride separate rails. The session token is held in the shell-owned Service Worker, never readable by page or widget JS, and the SW attaches it to every outbound API call. Separately, at mount the shell mints an unforgeable per-instance token, held inside the SDK handle's closure and stamped by the SDK transport (x-gridmason-instance-token) on every call. The API maps that token to (instanceId, widgetId, declared capabilities) and rejects capability-scoped calls that lack a valid one.

The consequence: a widget that bypasses the SDK reaches the API with session auth but no instance token — it is, effectively, an anonymous page script, and every capability-gated route denies it. The honest framing is that same-document JS offers no hard isolation boundary, so this binding is enforcement plumbing plus an audit trail, not a sandbox. The real boundary remains reviewed signed code + capability-scoped SDK + CSP. The demo API implements this check as reference code, so the claim is exercised end to end here, not just asserted.

Auto-degrade and telemetry

Every widget runs behind a per-widget error boundary (from core): a failed remote becomes a fallback card with the widget's name and a retry, and the shell never blocks on widget code. Beyond failures, the shell attributes error and latency telemetry to each widget instance, and a widget that exceeds its budgets is auto-degraded to its fallback and flagged, with the degrade attributed to that specific instance on the telemetry stream (a console exporter by default, OTLP when VITE_GM_OTLP_ENDPOINT is set).

Using it as your dev host

The dashboard is the intended dev target while you build a widget. The loop:

  1. Scaffold and serve your widget with gridmason dev (from @gridmason/cli), which serves the entry source and a live-revalidated manifest and hot-reload stream.
  2. Run the dashboard's Vite dev server with dev sideload enabled — GRIDMASON_DEV_SIDELOAD=1 and GRIDMASON_SIDELOAD_MODE=dev — and run the demo API alongside if you want real persistence.
  3. In the Add-widget picker, acknowledge and register your dev origin. The widget hot-loads, mounts through the same canvas + SDK path a registry widget would, and carries a distinct sideload badge.
  4. Edit and re-serve. Data and content your widget re-reads on mount hot-reload live; a change to the widget's own code needs a mount restart or page reload (the tag-redefinition limit above).
  5. Run gridmason lint to check your widget against the same automated checks a registry review runs.

Because the widget mounts through the exact shared canvas, SDK, error-boundary, and (in Phase B) verification path a production widget uses, what you see in the dashboard dev host is what a real deployment renders — minus, in Phase A, the signed verification chain, which is why the sideload caveat says to run only code you trust.