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 typedrecord-refcontext 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/:entityIdmust fall back toindex.html, exactly asdocker/nginx.conf'stry_files … /index.htmldoes. Configure your host's equivalent rewrite. - Security headers.
docker/nginx.confis the reference for theContent-Security-Policy(includingframe-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 islocalStorage-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 authorizedscript-srcadditions 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.defineas 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/protocolimports 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-srccarries 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-srccarries no third-party host. Anet:<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 widenconnect-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:
- 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. - Run the dashboard's Vite dev server with dev sideload enabled —
GRIDMASON_DEV_SIDELOAD=1andGRIDMASON_SIDELOAD_MODE=dev— and run the demo API alongside if you want real persistence. - 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.
- 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).
- Run
gridmason lintto 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.