ADR-035: The web UI is an opt-in Vue client in web/, served under /ui from the assets root, with a public shell¶
Status¶
Accepted.
Amends ADR-013 (the assets root gains the consumer it was built for, and
its first embedded entry),
ADR-034 (web/ is a fourth tree under the one root, and it is not an agent
integration),
ADR-014 (a second public path, narrowly) and
ADR-015 (the dashboard signs in the way the database console does).
Context¶
Issue #63 asks for a web interface to manage and inspect a MemCastle instance: the palace overview and health, wings, rooms and drawers, search, the knowledge graph, the diary, jobs, mining and maintenance, and basic configuration. It must be a client of the same services as the CLI and the integrations, must follow the asset distribution of sources and integrations (a configurable data root, packaged assets, a worktree in development), must respect memory modes and privacy rules, and must not touch SurrealDB.
Five decisions follow, and each has an alternative that is easier for one of the forces and worse for the others.
- Where the files live, and what they are called.
- Whether the daemon serves them at all unless asked.
- How a browser authenticates when the only credential the daemon accepts is an
Authorizationheader, which a browser cannot send when it navigates. - What the dashboard may do, and what API it needs.
- How it is built and packaged without a second release process.
Decision¶
- The canonical name is
web, and the layout is the same in a checkout and in a package. The source isweb/(a bun package), the build isweb/dist/, and a package installs it at<prefix>/share/memcastle/web/dist/. The daemon asksAssets::find("web/dist/<path>"), so development is--assets-dir <checkout>aftermise run web:build, exactly as for integrations (ADR-034), and a package is found through the executable's prefix with no flag. There is one lookup, one data root and noweb_dirsetting.web/dist/is ignored by git and produced bypackaging/web/build.sh, the script the release andmise run web:packageboth run. - It is opt-in:
web.enable(MEMCASTLE_WEB_ENABLE), false by default. A daemon that was not asked for a UI answers no/ui: the routes are not merged, so the authentication layer answers an anonymous request with 401 and an authenticated one with 404, as for any path that does not exist. There is no CLI flag:daemon startalready passes the environment and the file on. - Enabled but not installed is a 503 with the remedy, never a failed start.
Assets::EMBEDDEDgains one entry,web/dist/index.html: a small page saying the dashboard was enabled and its files were not found, with the three ways to fix that. A builtweb/dist/index.htmlin the chosen directory outranks it (the rule of ADR-013), so the page exists exactly when the files do not. The REST API and MCP are unaffected, and the startup log warns. Embedding the dashboard itself stays rejected (ADR-013): it would bloat a headless binary and tie every UI fix to a daemon release. - The static shell is public, and nothing else is.
With
web.enable,GETandHEADof/uiand anything under/ui/pass the authentication layer without a token. The files hold no data and are the same for everyone; without this a browser could never load the page that asks for the token. The layer still wraps the merged router,is_publicis still the one place that names an exception,/uixand every/apiand/mcproute are guarded as before, and a daemon with the dashboard off keeps/uiguarded. The static handler adds a restrictiveContent-Security-Policy(own origin only, no framing),nosniffand ano-referrerpolicy, caches only the content-hashed files underassets/, and never serves a map. - The login is the database console's sign-in, over REST.
When the daemon requires a token, the SPA shows a login page with the user
memcastleand the token as the password, the same identifier and the same credential assigninin ADR-015: no account, no new secret, no cookie and no new route. The token is checked by sendingAuthorization: BearertoGET /api/status, whichAppServices::authenticateanswers like every request, so a token that is rotated or revoked stops working on the next call and the page returns to the login. A refusal says "The user or password was not accepted" and never which half was wrong or what was typed. The token lives insessionStorage(closing the tab signs out), never in the URL orlocalStorage. The database console's five-refusals limit is per WebSocket connection and has no REST counterpart, so there is none. Whether to show the login is learned from the firstGET /api/status: 200 means no token is needed, 401 means one is. - The dashboard is a client and carries a memory mode.
Every request carries
X-MemCastle-Mode(fullorread_only, chosen in the sidebar), and a refusal from the daemon is shown, not swallowed. The interface hides or disables what the daemon would refuse (applying a repair, mining, embedding, extracting, writing), but the daemon is what refuses. The first version submits jobs, controls them, writes the diary and notes, and reads everything else. It has no shutdown, no source installation and no token management: those stay CLI and REST, as invariants 6 and 10 require. - Three read-only routes make it possible, and nothing else was added.
GET /api/jobstakeskindandlimit(either makes the answer a bounded page, newest first, default 50, at most 200; without both it is unchanged).GET /api/graphanswers entities and the open facts between them in one request, for one entity's neighbourhood (depth 1 to 3) or an overview, capped and saying when it stopped.GET /api/configanswers the configuration in effect from an explicit list of fields, never a serialisation ofConfig, so a secret or an endpoint URL cannot appear by being added to the file format. The first two are reads under ADR-007; the third is daemon information likestatusand is not gated by the mode. - The code is Vue 3 with OpenVue, and
web/is its own package. OpenVue is the MIT continuation of PrimeVue; it is the component library, with its Aura theme and a light, dark or system mode.web/has its ownpackage.jsonandbun.lockand does not import from an integration: it installs and builds alone, as ADR-022 decided for the integrations. The few lines that find a daemon for the dev server are copied from the integrations' client, not imported. Hash routing means the daemon needs no rewrite rule for a deep link. - Pages are read on demand, by a Refresh button, and neither poll nor push. Each page that shows changing data reads it when it opens, when the button is pressed and after the user's own actions, and says when it last succeeded. A timer would be a request per page per interval whether or not anything changed. A WebSocket or server-sent stream would need an event bus out of the scheduler and the writers, a new route the authentication layer must admit (a browser WebSocket cannot send a header) and its own tests; that is a larger decision than the dashboard, and is left to issue #215.
- A guard holds
web/to HTTP, as one holdsintegrations/. Theweb-http-onlyhook fails onsurrealdb,surrealkv,SurrealStore, astoreorjobspath or/api/dbunderweb/(Markdown,node_modulesanddistexcepted): invariant 11. - Tests cover discovery, packaging, integration and the missing build.
src/assetstests prove a packaged layout and a worktree resolveweb/dist/index.htmlthrough the same lookup and that without it the embedded page stands in.tests/in_process/web.rsandauth.rsprove the opt-in, the headers, the traversal refusals, the public shell, and that every/apiroute stays guarded.tests/web_bundle.rsruns the release's script, checks what the package holds, and serves it from an installed prefix and from the checkout.web/testholds the client, the login and the route guard (vitest) and runs the same client against a real daemon (bun).mise run web:checkruns all of it and is incheck; CI has a job for it and the packaging job lists the file.
Alternatives rejected¶
uiordashboardas the directory name. The issue allows any of the three and asks for one.websays what it is to someone who has never seen the product, and it is the name of the setting.- A separate
web.dirsetting.assets.diralready means where the files that ship with MemCastle are (ADR-034 rejected the same for integrations). - Embedding the built dashboard in the binary. Rejected in ADR-013 and not reopened: the fallback page is a few hundred bytes.
- Always serving the dashboard. A daemon on a server that does not want a UI would answer one, and the one path that bypasses authentication would exist on every installation.
- Keeping
/uiguarded. The dashboard would work only with authentication off, or behind a proxy that injects a header: no one who turned on authentication could use it. - A cookie or a token in the query string.
A cookie needs a login route that sets it, which is a new credential flow and a CSRF surface on a daemon that is otherwise
stateless; a token in a URL lands in logs, history and
Referer. - A login route that exchanges the token for a session. It would give the database console's identifier a second meaning, and a revoked token would have to be tracked in two places.
- Serving files with
tower-http'sServeDir. It would not go throughAssets::find, so the embedded page, the root-escape refusal and the one-lookup rule would each have to be rebuilt around it. The handler is a screenful. - Importing the integrations' daemon client.
It is MCP-oriented and brings
smol-tomland the MCP SDK, and the web package would no longer install on its own. - Fanning out over
/api/entitiesfor the graph. One request per entity per view is what a bounded server-side answer exists to avoid.
Consequences¶
- A fourth tree,
web/, sits under the assets root besidesources/,integrations/andskills/. A release gainsmemcastle_<version>_web.tar.gz, deb and rpm carry/usr/share/memcastle/web, and the AUR and Homebrew recipes fetch the new asset. - Building a release needs node as well as bun, and the CI packaging job lists
web/dist/index.html. - There are two public paths on a daemon with authentication on and the dashboard enabled, and they are tested as such: the liveness probe and the static shell.
/api/configand/api/graphare part of the REST contract and documented indocs/mcp-and-api.md; there is deliberately no MCP counterpart, since an agent has the tools it needs and a dashboard's views are for people.- A token typed into the login page travels in cleartext over plain HTTP, as it does for the REST API and the database console:
web.enableon a non-loopback bind belongs behind TLS (docs/authentication.md). - Source maps are not built, so a browser's developer tools show minified code of a release.