Skip to content

The dashboard

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

Nerthus.Core.Dashboard is a static single-page app over the daemon's API, published as GitLab Pages at astral.nerthus.pl. It holds no lore, no roster, and no state a reload cannot rebuild: every fact on screen came from one of the daemon's routes through the edge, and every change it makes goes back out through one. This page covers what the app is and how it is built. Deploy the dashboard covers the machines, and Use the web dashboard covers what a Narrator does with it.

The repository is nerthus.core.dashboard. It is a seventh component of the ecosystem, not part of Nerthus.Core — Ecosystem overview places it, and Architecture owns the daemon contract it consumes.

Concepts

The hash is the state. Every view is a route under #/, so any screen a Narrator is looking at is a link they can paste. There is no tab state beside the URL: src/ui/tabs.ts maps the path the router resolved back to the tab that should read as active, never the other way round.

Reads first, staged writes second. Most of what the app calls is the read surface — projections of the published generation, replica-safe. It also writes, through typed wrappers like every read: the editing plane (POST/PUT/DELETE on /edits, and POST /edits/{eid}/publish), POST /maps/layout, the three workflows, and the moderation writes. The workflows, the publish and the export each carry a ?dryRun=true arm, so a surface shows the consequence before committing it.

One bundle, two fleets. The same build serves the production and dev origins. src/config.ts holds a HOST_DEFAULTS map from hostname to API base, so the page reads window.location.hostname to pick its fleet:

const HOST_DEFAULTS: Readonly<Record<string, string>> = {
  "astral.nerthus.pl": "https://evocation.nerthus.pl/v1/api",
  "dev-astral.nerthus.pl": "https://dev-evocation.nerthus.pl/v1/api",
};

A stored endpoint wins over the hostname default, so a Narrator who has chosen one in Ustawienia keeps it.

The routes and the tabs

Fifty-four route patterns, twenty-three view modules, sixteen tab-owning destinations — counted at Nerthus.Core.Dashboard 738b3cc on 2026-08-12 out of route-table.ts, src/ui/views/ and the distinct tab values in destinations.ts. src/ui/route-table.ts names every address, src/ui/destinations.ts holds one row per pattern, and src/ui/route-views.ts binds each row to the view that draws it — src/main.ts imports the bound table and mounts the router over it. The two tables below are the whole 54 and are meant to stay that way: an address the router carries and this page omits is an address no reader can discover.

destinations.ts is one table whose rows carry five projections: which tab reads as active, whether the view is read wide, whether the card strip stands over it, whether the first segment is a cast of entity names, and which pills the bar draws. An address the table does not carry belongs to no tab and renders a not-found page. The one path claimed by pattern rather than by literal is the one-segment /:names, which is a cast of entity names — #/Eraster opens the transcript reader on that cast.

The sixteen destinations that own a tab, grouped into the four nav groups (lore, moderacja, rada, it; Start, Szukaj and Ustawienia stand outside the groups):

Tab Label Default path Group
start Start #/
board Transkrypty #/transkrypty lore
hygiene Higiena #/higiena lore
repo Repo #/repo lore
editor Edytor #/edytor lore
sessions Sesje #/sesje lore
minimapa Minimapa #/minimapa lore
moderacja Moderacja #/moderacja moderacja
kary Kary #/kary moderacja
dyzur Dyżur #/dyzur rada
wladze Władze #/wladze rada
regulamin Regulamin #/regulamin rada
fleet Flota #/flota it
sejf Sejf #/sejf it
search Szukaj #/szukaj
settings Ustawienia #/ustawienia

/ is a Start landing page (ui/views/start.ts): what changed and what the corpus holds, before the reader asks a question. The transcript reader lives at #/transkrypty under the label Transkrypty, and its six context routes hang off the bare cast instead.

The remaining patterns, by the tab they light:

Patterns Tab
/:names, /:names/s/:header, /:names/q/:text, /:names/l/:place, /:names/l/:place/q/:text, /:names/l/:place/s/:header Transkrypty — a cast, filtered by session, phrase and place
/nicki Higiena — the speaker roster
/formy/:name Higiena — one name, and every form the corpus writes it in
/probki, /probki/rozstrzyganie, /probki/rozstrzyganie/:class Higiena — the sample census, and the queue a person rules on
/probki/douczanie, /probki/douczanie/:set Higiena — the finetuning store's queue: one model-written exchange against the passage it claims to be grounded in
/model Higiena — what the tools read the corpus with, and what could be turned on instead
/wladze/sygnaly Władze — where the ledger and the lore repository's own narrator table disagree
/repo/:path, /repo-encja/:name Repo — one file, and the file an entity's @plik names
/edytor/:path Edytor — one file open for writing
/sesja/:header Sesje — one session
/swiat, /swiat/pasma Minimapa
/dyzur/zamkniecia, /dyzur/rozliczenie, /dyzur/wybory, /dyzur/osoby, /dyzur/sesja/:header Dyżur — the five duty panes
/moderacja/rejestr, /moderacja/klucz Moderacja — the register, and the key import
/kary/nowa, /kary/przeglad, /kary/osoba/:subject, /kara/:id Kary — the moderation ledger
/sejf/dostepy, /sejf/nowy, /sejf/odzyskiwanie, /sejf/:id Sejf — the credential inventory
/regulamin/:doc, /regulamin/:doc/:version Regulamin — a document, and one version of its text
/szukaj/q/:text, /szukaj/f/:filters/q/:text Szukaj — the answer to a phrase
/logowanie sign-in, rendered in the reader's place
/spis Spis — every nick the corpus speaks under, and the entity behind it. /nicki keeps resolving into the same view, so links written to the older address still open it
/gitlab GitLab — what triggers what across the repository family, where the result lands, and how the runs went. Ungated, like the fleet: the topology describes the system rather than reading anything, and each live block refuses per pane to a reader whose session lacks the capability it costs
/ustalenia Ustalenia — the disagreements somebody has to settle, both readings side by side with their evidence. Its two write acts have no server target and are drawn blocked rather than omitted

Those three own tabs of their own, and the counts and the tab table above predate them: they were taken at 738b3cc and the nav model has since grown rails. What holds regardless is the rule this section opens with — every address the router carries is named here.

The two #/swiat addresses render the minimap tile grid (renderMinimapa) and light the Minimapa tab. The rows survive so a pasted #/swiat/pasma still opens what it named — /swiat/pasma opens the grid with the bands showing.

Nav is gated on capabilities. Every destination row names needs, a set of capabilities any one of which admits a reader, and three consumers filter against the principal: the pill strip at paint time in shell.ts, the command palette, and the router guard. log.read gates the transcript readers, session.read the session list, entity.read the tree, the world and search — so a Gracz or a chat moderator is offered only the surfaces their token can open, and a principal without entity.read lands on their first offered pill instead of #/.

The API surface it consumes

src/api/routes.ts is the whole dependency list. Every entry is a typed wrapper over one route in the closed table; the app reaches none of them directly.

Group Routes
Discovery GET /health
Auth POST /auth/margonem, GET /auth/whoami, POST /auth/link
Entities GET /entities, /entities/search, /entities/links, and the hits, lines, mentions, scenes, sessions, speech and file sub-reads
Search GET /search, GET /search/index
Sessions GET /sessions, /sessions/summary, /sessions/open, /sessions/integrity, /sessions/graph, /sessions/{header}, /sessions/{header}/logs, /sessions/{header}/close-impact, POST /sessions/batch
Duty POST /workflows/close-session, /workflows/award-pu, /workflows/settle (each with a ?dryRun=true arm), GET /config/schedules, /elections/eligibility, /governance/permissions, /contributors/integrity
People GET /people/channels, /people/{name}/characters, /identity/{id}, /pu/new-character-count, POST /characters
Editing POST /edits, GET /edits/{eid}, PUT /edits/{eid}/entries, DELETE /edits/{eid}/entries/{n}, DELETE /edits/{eid}, POST /edits/{eid}/publish
Maps GET /maps/layout, POST /maps/layout, POST /maps/dimensions
Logs GET /logs/speakers, /logs/archive/{log}
Lore files GET /files, GET /files/content
Hygiene GET /normalization/suggestions, GET /hygiene/orphans
Moderation GET /keyrings, /keyrings/{name}, /moderation/transparency + /transparency/root, /moderation/records, /moderation/records/{id}, /moderation/evidence/{sha}, GET+POST /moderation/disclosures, GET+POST /moderation/exports, DELETE /moderation/records/{id}/wraps
Regulations GET /regulations, /regulations/{doc}, /regulations/{doc}/{version} and its points, /regulations/effective, /regulations/tariff
Recovery GET /recovery/documents, POST /recovery/documents
Fleet GET /fleet/status, GET /fleet/peers, GET /events/journal
Stream GET /events — the SSE stream, parsed by src/api/events.ts

The lore-file reads are documented in Lore files and modelled in The lore-file read surface. The rest are in the API reference.

Three consumption details matter to anyone changing these calls:

  • The session listing is shelved. findSessionSummariesShelved reads GET /sessions/summary, keeps the parsed rows in an IndexedDB shelf (src/api/shelf.ts) beside the ETag they arrived with, paints from the shelf, and revalidates with a conditional request the daemon answers 304. On the campaign corpus the listing is 6.4 MB whole and 2.0 MB without prose, which is what the cheap revalidation saves on every return to the finder.
  • The stream opens before the run starts. GET /events is the one place the daemon speaks first, and the ordering is the contract: subscribe, then start the sweep. Reversed, the subscribe queues behind the work it wanted to watch and the first frame arrives when nothing is left to report.
  • Journal cursors live in a module-level Map, keyed by endpoint — deliberately in memory, not in storage. since=0 means "whatever you have", never "I was at zero", so a page reload starts over while leaving and re-entering the Flota tab does not re-show events or hide a gap that opened while the tab was closed.

Signing in

There is no refresh token. The player's Margonem cookies are the long-lived credential and a session token expires after session_ttl_s. On a 401 the app drops the stored token and shows sign-in again.

Two token kinds are accepted:

  • tok_… — a session or named token, minted by POST /auth/margonem or by an operator.
  • nak_… — an API key from a committed @klucz_api grant in the contributor store. It needs no sign-in flow and works from any origin, which makes it the practical credential for this deployment.

Margonem sign-in cannot complete from a Nerthus origin. The documented flow (Identity and the capability ACL) starts by posting to public-api.margonem.pl/account/validate with the player's cookies. That endpoint answers Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true, and the Fetch standard forbids combining a wildcard origin with credentials: "include" — the browser discards the response before the app sees it. The advertised method set excludes POST besides, and without the cookies the endpoint answers 400 Not found token.

So the envelope can only be minted where the Margonem cookie jar already is:

  • Handoff. The in-game addon on nerthus.margonem.pl performs the validate call and opens the app with the minted token in the query string. takeHandoffToken consumes it once and strips it from the address bar, so the credential does not survive in history or a bookmark. redeemEnvelope is the second half of the flow.
  • Direct. Only where the app is served from an origin the browser treats as same-site with margonem.pl — a dev proxy. fetchEnvelope reports precisely why it failed rather than saying sign-in did not work.

The browser crypto layer

src/crypto/ opens moderation records and evidence in the browser. The daemon serves ciphertext; the age wrap opens with the member's identity, and WebCrypto's Ed25519 verifies the parsed SSHSIG over the record's payload against the keyring's published signing keys — nothing is drawn as authentic before that verdict resolves, and a key outside the ring is its own verdict rather than a pass.

The age identity enters at #/moderacja/klucz and nowhere else. The screen checks it by derivation: the pasted secret's derived public half must equal the @klucz_szyfr the keyring publishes for the member claiming it, and a key matching a different member is refused by name. A loaded identity lives in memory in one tab — not localStorage, not a cookie, not a URL — so closing the tab is what forgetting looks like. subject_tag is computed client-side from the member-held K_tag (src/crypto/tag.ts), so a nick search never sends the nick.

src/crypto/blob.ts defines a server-side key blob for the second-device case: the payload sealed under a content key, and the KEK derived as HKDF-SHA256(scrypt(passphrase, salt) ‖ daemon-share), with the scrypt defaults costing about 128 MiB per attempt. wrappers[] is a list with passphrase implemented and prf (WebAuthn) declared, carrying its salt, and unimplemented. Nothing calls the module: the daemon route that stores a blob and releases the per-moderator share does not exist, so the only way in is the key screen. rotatePassphrase re-wraps at the current SCRYPT_DEFAULTS unless next.params overrides.

Module layout

Path Holds
src/main.ts the app bootstrap: endpoint, session, shell, router
src/app.ts AppContext — the client, the principal, the store
src/api/ client.ts (fetch, auth, errors), routes.ts (one wrapper per route), events.ts (SSE frame parsing), types.ts, logCache.ts, shelf.ts (the IndexedDB shelf)
src/auth/ margonem.ts (the envelope), session.ts (the principal and the three ways a token arrives)
src/crypto/ the browser crypto layer: age, aead, blob, identity, record, sshsig, tag
src/config.ts the endpoint resolution, HOST_DEFAULTS, token storage
src/ui/route-table.ts, destinations.ts, route-views.ts the path vocabulary, the one destination table, and the destination-to-view binding
src/ui/views/ one module per view
src/ui/board/ the transcript reader's parts: field, hits, panel, roster, scenes, shared, spy, stream
src/ui/dyzur/, editor/, kary/, moderacja/, sejf/ the panes behind the Dyżur, Edytor, Kary, Moderacja and Sejf tabs
src/ui/menu/ the command palette, which the not-found page also mounts inline
src/ui/table.ts the one shared table component; the session list and the explorer render through it
src/ui/ router.ts, shell.ts, tabs.ts, store.ts, context.ts, teardown.ts, markdown.ts and the rest of the chrome
src/styles/ one stylesheet per surface, all imported from app.css
tests/ Vitest suites in jsdom, one file per module

A route render returns a disposer. ui/teardown.ts runs it when the route changes, which is what keeps a view's timers and listeners from outliving it.

Gates

Four CI jobs run before the build, each named so one failure names itself: lint (eslint), format (prettier --check), typecheck (tsc), and unit. npm run lint, npm run format, npm run typecheck and npm run test are the local equivalents. The unit job runs npm run test-coverage and publishes junit and cobertura artifacts; the coverage floor lives in vite.config.ts and the run itself enforces it, so a suite below the floor fails without a separate gate. The build is a static bundle with two runtime dependencies, pinned exactly — age-encryption 0.3.0 and @noble/hashes 2.3.0, which the browser crypto layer uses — and a green deploy of code that does not typecheck is the failure the job order exists to prevent.

See also