API reference¶
The daemon (nerthusd) exposes its entire functional surface over loopback HTTP as a
closed route table — fixed at build time in daemon/routes.ps1. Every consumer (the
Verb-Nerthus* cmdlets, the web dashboard, a future Discord/Margonem bot) reaches Nerthus
through this one API; if a behavior is not a route here, no client can do it. Each cmdlet
wraps exactly one route.
This page is the shared contract every endpoint obeys — base URL, auth, the cmdlet mapping, the middleware chain, envelopes, status codes, jobs, and the event stream. The per-domain pages below carry the route tables and a worked, executed example per route.
The pages¶
Each page owns one slice of the route table, in the Tussal/Eraster world:
- Discovery & health —
/health,/schema,/routes,/schema/version - Entities — the seven entity types: read, search, create, edit, retire, rename
- Name resolution —
/resolve, the name index - Players & characters —
/people,/characters, webhooks, character info - Sessions — search, integrity, the participation graph, the session workflows
- PU & elections — the monthly award batch, timelines, eligibility
- Currency & economy — holdings, denominations, snapshots, timelines
- Locations & maps — the location hierarchy, the map catalogue, traversal
- Logs & audit — log fetch/parse, the audit streams
- Discord — delivery
- Governance — narrator permissions
- Auth, identity & tokens — sign-in, capabilities, token lifecycle
- Settlement — the monthly settle run and the map checkup
- Admin & lifecycle — mode, import, sync, jobs, events, shutdown
Every example on these pages is executed¶
The request in each HTTP tab is the exact request CI sends against a real daemon on the
canonical Tussal/Eraster fixture; the documented
response is asserted. A wrong example turns the pipeline red — the reference cannot drift
from the code, because the reference is the test corpus (tests/DocExamples.Tests.ps1).
The JavaScript tab is a projection of the same request, shown for browser consumers, not
independently executed. Names in paths are URL-encoded; the examples run with a
full-capability token unless a section shows a scoped one.
Base URL and auth¶
- Base URL —
http://127.0.0.1:{port}/v1/api. The daemon binds127.0.0.1on an OS-assigned port (pin it with configserver.port), written to.nerthus/runtime/daemon.port; discovery and auto-spawn are in Architecture. - Versioning — the version lives in the path (
/v1/api/...); a breaking change would add/v2/api/...alongside. - Bearer auth — every request carries
Authorization: Bearer {token}. Token kinds (machine, session, named) and minting are owned by Permissions. - Capability — each route declares one required capability (
entity.read,pu.award,player.write.own);.owngrading and the role bundles are owned by Permissions, the id list by Capabilities.
The cmdlet mapping¶
Every Verb-Nerthus* cmdlet does the same three things: discover the daemon (walk up
to the nearest .nerthus/ marker, read runtime/daemon.port + daemon.token, auto-spawn
and wait up to 60 s for GET /health), make one HTTP call to the single route it wraps,
and project the JSON into [PSCustomObject]s (collection cmdlets emit one object per
item). A non-2xx throws one generic terminating error carrying the status and body; on a
401 the client re-reads the possibly-rotated token once and retries. A convenience that
needs two domain calls is a workflow route surfaced as one cmdlet
(Close-NerthusSession ⇒ POST /workflows/close-session). Verbs come from the approved
set (Code style); Find-* (search) is kept distinct from Get-*
(fetch by key).
-WhatIf ⇄ ?dryRun. Mutating cmdlets declare SupportsShouldProcess; -WhatIf
appends ?dryRun=true, so the daemon validates and reports the would-be change without
touching disk. A handful short-circuit locally instead (no HTTP call): Stop-Nerthus,
Set-NerthusMode, Set-NerthusSessionHash, Set-NerthusSessionGraph,
Invoke-NerthusSessionDistribution, Invoke-NerthusSessionLogFetch,
Invoke-NerthusReindex, Set-NerthusNarratorPermission, and
Get-NerthusSessionLog -Refresh/-FetchMissing. Three passthrough parameters:
| Parameter | Effect |
|---|---|
-Daemon <uri> |
Skip discovery; talk to an explicit daemon base URL. |
-Token <string> |
Override the discovered token (e.g. an identity-bound session token). |
-AsJob |
On Invoke-NerthusPUAssignment only: send ?async=true, return the 202 job handle. |
The middleware chain¶
Every request flows through one fixed pipeline; the route table is consulted in the middle and never extended at runtime:
Authenticate (401) → RouteMatch (404) → CapabilityCheck (403)
→ WriteGate (403) → ContentType (415) → body parse (400)
→ BeforeWrite (422) → dispatch
- Authenticate precedes RouteMatch: an anonymous probe of an unknown path is
401, not404. Only the public routes (—capability) answer without a token. - WriteGate: every
Write-flagged route funnels throughAssert-NerthusWriteAllowed, which refuses with403in read-only mode, on a not-yet-adopted repo (SchemaTooOld), on index-format drift (SchemaTooNew), or on a stale sync (SyncStale). The Write flag is per route, never derived from the method — a read-only POST (e.g.POST /resolve) is never gated. - BeforeWrite fires after the gate; a throw rejects the write
422 WriteRejected. Closed-schema validation (unknown@typ/tag) lives in the handlers. - dispatch also fires the
After*hooks (audit, SSE, Discord); a throw there is a warning — the write already stands. The hook list is owned by Architecture.
The Write flag marks routes that mutate lore/index files, plus durable
governance/identity state and the log-fetch archive writes. Deliberately unflagged: the
control-plane recovery routes (POST /import, POST /admin/mode, POST /admin/shutdown,
POST /sync) that must stay operable to recover from a lock; POST /discord/send
(writes only delivery state); and the in-memory rebuilds (POST /name-index/rebuild,
POST /sessions/graph).
Envelopes¶
Mutating bodies are JSON. Responses are always JSON, in one of three shapes:
- Single object — one entity, session, or report, returned directly.
- List envelope —
{ "count": N, "items": [ … ] }, never paginated (loopback, single-consumer). A few routes name their collection after the domain (GET /routes⇒routes,GET /pu/history⇒assignments,GET /economy/timeline⇒points,GET /capabilities⇒capabilities,GET /entities/{name}/sessions⇒sessions,GET /sessions/narrator/{nick}/profile⇒sessions). The wrapping cmdlet unwraps whichever the route uses. - Job envelope — the monthly PU batch (
POST /workflows/award-pu) runs as an in-daemon job. It executes synchronously; by default the route returns200with the result inline plus aJobId.?async=true(-AsJob) returns202+{ jobId, statusUrl, status }; pollGET /jobs/{id}(completed/failed) andGET /jobs/{id}/result. Jobs are synchronous — no progress stream, no cancel.
Every mutating route accepts ?dryRun=true: validation runs and the response describes the
would-be change without touching disk. Two exceptions still run their network step under a
dry run — POST /sync (refreshing origin refs is the point; it skips commit/push and
reports wouldPublish) and POST /sessions/graph (an in-memory rebuild).
Status codes¶
| Code | Meaning |
|---|---|
200 |
Success (including every dry run). |
201 |
Resource created (New-Nerthus*, an added character-info entry). |
202 |
Job accepted (?async=true); poll statusUrl. |
400 |
Malformed JSON body or missing required parameter. |
401 |
Missing or invalid Bearer token (also anonymous probes of unknown paths). |
403 |
Capability denied, an own-scoped caller targeting a foreign subject, or the write gate refusing (SchemaTooOld / SchemaTooNew / SyncStale). |
404 |
Route not in the closed table, or resource not found. |
409 |
Conflict — an ambiguous Margonem id, a settle op the ledger echo already lists (PULedgerDrift / TransferLedgerDrift), a stale character-info match, an already-taken (name, type), a webhook overridden by an overflow block, or a POST /sync a daemon cannot run (SyncDisabled / NotAGitRepo / GitMissing / NoSyncBranch). |
415 |
Missing/wrong Content-Type on a mutating call. |
422 |
Body parsed but failed domain validation — unknown @typ/tag, PUUnresolvedCharacters, a BeforeWrite rejection. |
500 |
Unhandled handler error (its message is the body's error). |
Error bodies are contract. Every error carries { "error": "<id>", … }; the id string
is stable and tested. SchemaTooOld fires in read-only mode and on an un-adopted repo — the
remedy is Initialize-NerthusRepo (POST /import). SchemaTooNew fires when a clone pulled
an index-format bump its module does not understand yet. SyncStale carries the remedy
ladder — the rule is owned by Sync;
PUUnresolvedCharacters returns its grouped unresolved[] body
(PU model).
Server-Sent Events¶
GET /v1/api/events (capability events.subscribe) keeps an open text/event-stream,
handed to a dedicated pump thread so it never stalls ordinary requests. Frames are named
events with sequence ids (id: 42 / event: pu:awarded / data: {…}), a keepalive comment
every 30 s, and ?since=<seq> replays buffered events after <seq> (?since=0 = the whole
buffer). Auth is in the Authorization header, never the URL — a browser consumes the
stream with fetch + ReadableStream, not EventSource.
| Event | Fires when |
|---|---|
entity:write |
An entity is created/updated/soft-deleted. |
session:distributed |
A session was distributed to its participants' files. |
pu:awarded |
A monthly PU batch committed. |
transfer:applied |
A session's @Transfer directives were applied to holdings. |
governance:changed |
A narrator's Uprawnienia row was edited. |
sync:completed |
A sync tick finished — data carries the action, outcome, and SHAs (Sync). |
See also¶
- Architecture — the daemon/thin-client contract, discovery and auto-spawn, hooks, self-heal
- Permissions · Capabilities — tokens, the capability ACL,
.ownscoping, role bundles - Code style — the approved-verb gate · Configuration —
server.portand the tunable constants - The domain models behind the routes: sessions · PU · currency · locations · name resolution