Skip to content

Admin & lifecycle

The control-plane routes — the ones that must stay reachable even when data writes are frozen. They switch the daemon between read-only and read-write, re-run adoption (/import), report and drive repo sync, poll background jobs, expose the live event stream, and finally stop the daemon. /admin/mode, /import, and /sync deliberately carry no write gate so a not-yet-adopted or read-only repo stays recoverable.

The fixture is a plain directory, not a git clone, so sync is disabled on it — the examples below prove exactly that state. Each example runs against a live daemon — see how the reference is tested. Reads and writes here need the matching admin capability (admin.mode, admin.migrate, sync.read, sync.run, admin.shutdown).

Routes

Method Path Cmdlet Cap Write
GET /events — (SSE stream) events.subscribe
GET /jobs/{id} entity.read
GET /jobs/{id}/result entity.read
POST /admin/mode Set-NerthusMode admin.mode
POST /admin/shutdown Stop-Nerthus admin.shutdown
GET /sync Get-NerthusSync sync.read
POST /sync Invoke-NerthusSync sync.run
POST /import Initialize-NerthusRepo admin.migrate

Cap is the required capability ( = public, no token); Write () marks routes that pass the write gate; a in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.

  • These are control-plane routes, deliberately write-gate exempt so they stay reachable to recover from a lock. POST /import clears a SchemaTooOld 403 on an un-adopted repo; ?dryRun=true previews the index without writing (Adoption).
  • No role bundle carries sync.read / sync.run — both resolve through admin.all or a per-Person grant; the scheduled tick runs inside the daemon and needs no token. The sync subsystem: Sync.

Freeze writes (read-only)

POST /v1/api/admin/mode with { "mode": "read-only" } puts the daemon in ReadOnly; every write-gated route then answers 403 { "error": "SchemaTooOld" }. The route itself is never gated, so it always works.

POST /v1/api/admin/mode
Content-Type: application/json
Authorization: Bearer <token>

{ "mode": "read-only" }
await fetch("https://evocation.nerthus.pl/v1/api/admin/mode", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ mode: "read-only" }),
}).then((r) => r.json());

Response 200:

{ "mode": "ReadOnly" }

Resume writes (read-write)

{ "mode": "read-write" } returns the daemon to ReadWrite so data routes accept writes again.

POST /v1/api/admin/mode
Content-Type: application/json
Authorization: Bearer <token>

{ "mode": "read-write" }
await fetch("https://evocation.nerthus.pl/v1/api/admin/mode", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ mode: "read-write" }),
}).then((r) => r.json());

Response 200:

{ "mode": "ReadWrite" }

Re-run adoption

POST /v1/api/import runs the idempotent importer: it rebuilds the single index, files free roster notes into the character files, and stamps the format version. Applied is true on a real run (?dryRun=true previews without writing). The counts describe the emitted index.

POST /v1/api/import
Content-Type: application/json
Authorization: Bearer <token>

{}
await fetch("https://evocation.nerthus.pl/v1/api/import", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: "{}",
}).then((r) => r.json());

Response 200 (trimmed):

{ "Applied": true,
  "Entities": { "players": 2, "characters": 2, "npc": 3, "locations": 7, "maps": 12, "preserved": true },
  "SessionsObserved": 1 }

Sync status

GET /v1/api/sync reports which commit the daemon serves and its scheduler state. On the fixture — a plain directory, not a git repo — sync is off: enabled, git, and stale are all false.

GET /v1/api/sync
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/sync", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200 (trimmed):

{ "enabled": false, "git": false, "stale": false, "remote": "origin" }

Trigger a sync (disabled)

POST /v1/api/sync runs one fetch/converge tick — but only where sync is enabled host-locally. On a daemon with sync off it refuses outright with 409 { "error": "SyncDisabled" } rather than publish an uncommitted worktree.

POST /v1/api/sync
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/sync", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 409:

{ "error": "SyncDisabled", "detail": "sync is not enabled on this daemon — enable it host-locally (.nerthus/local/sync.json or -SyncIntervalMinutes) and restart" }

Poll a background job

GET /v1/api/jobs/{id} returns the record for a background job (the 202 envelope some workflows emit). An unknown id is 404 { "error": "job not found" }.

GET /v1/api/jobs/nieistniejacy
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/jobs/nieistniejacy", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 404:

{ "error": "job not found" }

Fetch a job's result

GET /v1/api/jobs/{id}/result returns { jobId, status, result } for a finished job; an unknown id is likewise 404.

GET /v1/api/jobs/nieistniejacy/result
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/jobs/nieistniejacy/result", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 404:

{ "error": "job not found" }

Subscribe to events (anonymous)

GET /v1/api/events is the live SSE stream — connect with Accept: text/event-stream and a token carrying events.subscribe. Without any credentials it is 401 { "error": "Unauthorized" }.

GET /v1/api/events
await fetch("https://evocation.nerthus.pl/v1/api/events").then((r) => r.json());

Response 401:

{ "error": "Unauthorized" }

Subscribe to events (missing capability)

A valid token that lacks events.subscribe — here one minted with only entity.read — is rejected with 403 { "error": "Forbidden" } before any stream opens.

GET /v1/api/events
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/events", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 403:

{ "error": "Forbidden" }

Stop the daemon

POST /v1/api/admin/shutdown returns 200 { "stopped": true } and then stops the process — it is always the last call you make against a daemon.

POST /v1/api/admin/shutdown
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/admin/shutdown", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "stopped": true }