Skip to content

Permissions and identity

Nerthus.Core separates machine trust from end-user identity. A single loopback machine token authenticates local processes (cmdlets, the dashboard, scripts) against nerthusd with full capability. End-user identity — a Gracz opening a Discord slash command, a Narrator authenticating from a Margonem add-on — is verified from an external proof (Margonem signature or Discord OIDC), resolved to a Margonem ID (the one authoritative identity), and minted a short-lived, identity-bound session token whose powers a capability ACL governs. This page explains both layers, identity linking, the governance data (Uprawnienia), and the private state it all lives in. For the plain-language view of who the roles are, see the roles introduction; for the capability id list, grading, and role bundles, see the capabilities reference.

Concepts

Term Meaning
Machine token The daemon's own bearer token; full capability; loopback processes only
Session token A short-TTL token bound to one Margonem ID, minted after external auth
Named token An operator-issued token with a fixed capability set (e.g. for the dashboard)
Margonem ID The Margonem profile id (e.g. 9826541) — the authoritative who is this
Identity link A Discord account ⇄ Margonem ID binding stored in .nerthus/state
Capability A grantable permission named <resource>.<action>[.own]
Role A named bundle of capabilities (Gracz / Narrator / Radny / Namiestnik)
Person The unified identity record: one Margonem ID, optional Discord, a Gracz entity, a capability set
Uprawnienia The narrator permissions table — one row per Narrator, keyed by Margonem ID
Election eligibility Whether a Gracz is UPRAWNIONY to vote in the yearly Rada election

A Person is the join of an existing Gracz entity (keyed by @margonemid) with its linked Discord account and its resolved capability set. Identity answers who is it; the capability ACL answers what may they do.

Layer A — the machine token

The daemon owns all data over loopback HTTP; the machine token is how local processes prove they are on this machine.

  • At startup nerthusd mints a random token and writes it to .nerthus/runtime/daemon.token with mode 0600 (alongside daemon.port, daemon.pid) — gitignored runtime state the client discovers, never committed (discovery and auto-spawn are specified in the architecture).
  • Every request carries Authorization: Bearer <token>.
  • Rotation on restart. Restarting the daemon mints a fresh token. A cmdlet that gets a 401 re-reads daemon.token once and retries — an operator never re-authenticates after a daemon bounce.
  • The machine token implicitly satisfies every capability (admin.all). It is the credential the dashboard and all Verb-Nerthus* cmdlets use.

This layer authenticates the machine user, full stop — its only lifecycle is rotation at restart. Capabilities exist to scope end users reaching the daemon through the Discord/Margonem bot; the operator's own shell always holds full capability. Everything richer is Layer B.

Layer B — end-user identity

End users never see the machine token. They reach the daemon through the Discord/Margonem bot, which authenticates them and then calls the daemon on their behalf with an identity-bound session token. Two providers feed Layer B; both converge on a Margonem ID.

Margonem auth (POST /auth/margonem)

The proven live flow. A Margonem browser add-on already holds the player's session, so it can mint a Nerthus token without any operator-issued credential:

  1. The add-on POSTs to Margonem's https://public-api.margonem.pl/account/validate with the player's cookies. Margonem returns a signed envelope: { user_id, token, ts, validatedString, signatureBase64 }.
  2. The add-on forwards that JSON verbatim to Nerthus: POST /auth/margonem with body { "payload": "{…}" }.
  3. The daemon reconstructs the signed string itself as "{user_id}+{token}+{ts}" — the supplied validatedString field is ignored; only the components are trusted. It decodes signatureBase64 and verifies the signature against the cached public key using RSA-SHA256, PKCS#1 v1.5 padding.
  4. The public key is fetched from integrations.margonem.signing_key_url on first use and cached on disk (cache/signing-key.pem); the cache refreshes after signing_key_cache_ttl_s, and a failed refetch keeps the last good key (defaults in the configuration reference).
  5. Freshness gate: abs(server_now − ts) ≤ auth_timestamp_skew_s (default 300 s) — a replayed stale payload is rejected (see failure responses).
  6. The daemon resolves user_id to a Gracz by matching the @margonemid tag — only Gracz-typed blocks match, because imported Mapa blocks carry @margonemid too (game map ids). No match → 404; several matches → 409 — a data error the caller must see, never an arbitrary pick. Resolution is status-agnostic — a soft-deleted (@status: Usunięty) Gracz still resolves; revocation is explicit.
  7. The daemon mints a session token bound to that Margonem ID carrying the gracz role, with TTL session_ttl_s (default 4 h), and returns { token, identity: { margonemId, gracz }, capabilities, expires }.

There is no refresh token: the player's Margonem cookies are the long-lived credential. On 401 + WWW-Authenticate: Bearer error="invalid_token", the add-on replays from step 1.

Discord OIDC (POST /auth/discord)

The daemon verifies a Discord id_token (a compact RS256 JWS) against Discord's published JWKS (fetched from integrations.discord.jwks_url, cached at cache/discord-jwks.json), yielding a Discord user id from the sub claim. A Discord identity on its own is not authoritative. Until it is linked to a Margonem ID, the daemon refuses (403) and mints nothing. Once linked, the daemon resolves the linked Margonem ID to its Gracz exactly as Margonem-auth step 6 — the same live lookup, the same 404/409, never a name stored at link time — and mints the same gracz-role session token, bound to that Margonem ID. This is what lets a Narrator run /sesja zamknij from Discord and have the action attributed to their Gracz.

Why Margonem ID is authoritative

The Margonem profile id never changes while nicks are fungible (see the player model); @margonemid on the Gracz entity is therefore the join target for every identity proof, token binding, capability grant, and audit attribution.

Identity linking and introspection

Connect-NerthusIdentity binds a verified Discord account to a Margonem ID (capability identity.link, a gated write). The daemon resolves the Margonem ID to its Gracz first (404/409 exactly as in the sign-in flows). A Margonem ID maps to exactly one Gracz and at most one Discord account — re-linking either side revokes the prior bond. Links live in .nerthus/state/identity-links.json, private daemon state that is never committed (the state/ privacy rule is owned by the architecture page):

{
  "links": [
    {
      "margonem_id": "9826541",
      "discord_id": "184442xxxxxxxxxx",
      "linked_at": "2026-06-30T18:04:11Z",
      "linked_via": "margonem"
    }
  ]
}

A link record holds ids only — no store ever snapshots the Gracz name. The name is resolved live from the index on every use (the daemon's Resolve-NerthusGraczByMargonemId), so a Gracz rename can never leave a stale copy behind; a legacy record still carrying a stored gracz field is ignored.

Introspection (routes, envelopes, and required capabilities in the API reference):

  • Get-NerthusWhoami — the caller's own identity for the presented token; the effective view, including per-Person grants/revokes (the machine token reports kind: machine).
  • Get-NerthusIdentity — resolves a Person by Margonem id or Gracz name and reports the seeded Gracz bundle, never identity links or per-Person grants (deferred); the effective view is whoami.
  • Get-NerthusCapability — the caller's own effective capability set; there is no person selector — auditing another Person's grants is a capability.admin concern.

The capability ACL

Authorization is a full capability ACL. A capability is <resource>.<action> with an optional .own ownership suffix. The wildcard form <resource>.all grants every action on the resource; admin.all grants everything. Examples: entity.read, session.write, pu.award, pu.read.own, governance.write, token.manage. The complete closed id list, with what each capability grants and which role bundles carry it, is the capabilities reference.

Some capabilities deliberately sit in no role bundle: workflow.settle spans distribution, currency, and PU at once, so only admin.all resolves it (see the settlement model); sync.read and sync.run follow the same precedent — they resolve through admin.all or a per-Person grant, and the scheduled sync tick runs inside the daemon and needs no token at all (the sync subsystem is owned by the sync page). .own grading is meaningless for repo-level sync — the handlers treat an own-only grant as no grant and refuse with 403.

Grading and ownership (.own)

.own ties an action to the caller's identity. When the dispatcher checks a route's required capability, the caller's grant is graded full / own / none. The plain capability (or a matching .all / admin.all) grades full. Holding only the .own variant grades own and marks the request OwnOnly — the handler then scopes it to the caller's own subjects (their Gracz entity and every Postać whose @należy_do is that Gracz) or refuses it. None is a 403 naming the missing capability. X.own never silently satisfies X. The per-route OwnOnly scoping table lives in the capabilities reference. So a Gracz holding pu.read.own reads their PU timeline, and player.write.own lets them edit their own Postać.

Roles are capability bundles

The Nerthus roles are named bundles of capabilities, stored as data. They load from committed .nerthus/data-tables/roles.json; the daemon seeds that file only when it is absent, so a Council edit survives every boot and re-init, and the Council rebinds any role by editing the file. The seeded bundles, their differences, and the file's shape are in the capabilities reference and the data-tables reference.

A Person's effective set = union of their role bundles, plus per-Person grants, minus per-Person revokes (Grant-NerthusCapability / Revoke-NerthusCapability, persisted in state/capability-grants.json). Session tokens store only the role; capabilities are recomputed on every request, so a grant or revoke takes effect on live tokens immediately. Every externally-minted session token carries the gracz role — a Narrator's or Radny's extra powers attach to their Margonem ID as per-Person grants, or ride a named token minted with -Roles narrator.

Tokens at rest

Token records live in .nerthus/runtime/tokens/, one JSON file per token, named by the token's SHA-256 id — the file never stores the raw bearer value, so neither the directory nor any listing can leak a usable credential:

  • The raw value appears exactly once: in the mint response (POST /tokens, POST /auth/margonem, POST /auth/discord). Lose it, mint again.
  • Session tokens store { id, kind: session, margonem_id, roles, expires } — ids only, no Gracz name; capabilities are recomputed live.
  • Named tokens (New-NerthusToken -Name -Capabilities -Roles -TtlSeconds) store an explicit capability set snapshotted at mint (any -Roles bundle is expanded into it); TtlSeconds 0 means no expiry.
  • GET /tokens lists { Id, Kind, Name, Gracz, Expires } — hash ids, never values; the Gracz column is resolved live from each record's margonem_id. DELETE /tokens/{id} revokes by hash id (deletes the file; 404 if unknown). An expired token resolves to nothing — indistinguishable from an invalid one (401 either way) — and its record is swept from runtime/tokens/ at the next daemon boot.

Enforcement point

Capability enforcement is the CapabilityCheck (403) stage of the daemon's fixed middleware chain, whose stages, ordering, public no-auth routes, and status codes are specified in the API reference. A capability never bypasses the write gate: even admin.all is refused in read-only mode or on index-format drift (Assert-NerthusWriteAllowed).

Minting itself is deliberately not a gated write: /auth/margonem and /auth/discord write only private token records under runtime/tokens/, so sign-in keeps working in read-only mode — the same reasoning that exempts the control-plane routes.

Governance data (Uprawnienia)

Nerthus has a real-world governing layer: the Rada Świata Fabularnego (Council) elected yearly, a Namiestnik with standing oversight (bezkadencyjny — the office has no fixed term), and Narratorzy who run sessions under permissions the Council grants them. The tool tracks two governance datasets — the narrator permissions table (Uprawnienia) and the election voting roll — as private daemon state under .nerthus/state, kept out of the public lore repo and the Council's secret chmura-rady repo alike. Governance is data feeding the capability ACL above and future routing.

Note

Out of scope: Sprawozdania (narrator activity reports), Sprawy Graczy (player disputes), and any skill-check / "sprawdzajka" subsystem — they stay human-run in Discord and chmura-rady.

The table

Uprawnienia is the canonical statement of what each Narrator is allowed to run. It mirrors the Council's hand-kept table column-for-column; identity is the Margonem ID, the Nick is display only:

Column Meaning Cell vocabulary
Nick Narrator's current nick (display only; identity is @margonemid) free text
Ranga Standing in the narrator corps Narrator · Narrator początkujący · Narrator (Radny) · Narrator nieaktywny
Zatrudnienia May employ/recruit NPCs into player service TAK · Podstawowe · NIE
Magia Magic-content ceiling TAK · do 4/5 · do 3/5 · NIE
Polityka May run political/faction sessions TAK · NIE · Okres Próbny
Walki PvP May adjudicate player-vs-player combat TAK · do N graczy · NIE

Storage

.nerthus/state/governance/permissions.json holds a rows array, one record per narrator. An edit upserts the matched row in place (matched by margonem_id or nick) and stamps zmieniono; rows carry no date ranges — permissions history is deferred:

{
  "rows": [
    {
      "margonem_id": "9826541",
      "nick": "Eraster",
      "ranga": "Narrator (Radny)",
      "zatrudnienia": "TAK",
      "magia": "do 3/5",
      "polityka": "TAK",
      "walki_pvp": "do 4 graczy",
      "zmieniono": "2026-06-30T18:04:11Z"
    }
  ]
}

Reads are GET /governance/permissions (all rows, or one via ?id= — Margonem ID or nick). Writes are PATCH /governance/permissions/{nick} (the path segment also accepts the Margonem ID), a gated write honoring ?dryRun=true; a successful edit fires the AfterGovernanceChange hook, which publishes a governance:changed event on the SSE stream.

Cells are ceilings; capability projection is planned

Each cell records a ceiling — e.g. Magia: do 3/5 means the narrator may run/adjudicate magic content up to level 3 of 5. Ceilings make the table a routing pool: automation can select a narrator, not merely reject one — "is this narrator's Magia cap ≥ the session's magic level?", the same shape driving review assignment. A level-4 magic session is routable only to Magia: TAK or do 4/5 narrators; a 5-player PvP needs Walki PvP: TAK or do ≥5 graczy; Narrator nieaktywny rows leave every pool. Routing itself is future automation; today the cells are stored verbatim and read by humans and queries.

Projecting the cells into parameterized capabilities (Magia: do 3/5session.write{magia≤3}; Magia: NIE ⇒ no magic capability, magia=0; Ranga: Narrator nieaktywny ⇒ role bundle withheld) is planned, not enforced — deferred until the ACL covers every governed action.

Note

Invariant — Uprawnienia is the source; capabilities will be the projection. Per-Person Grant-/Revoke-NerthusCapability grants stack on top but never overwrite the table; the table is governance truth.

Election eligibility (consumer view)

The yearly Rada election needs a voting roll; GET /elections/eligibility (Get-NerthusElectionEligibility) computes it from the dated PU timeline — the WYBORY → UPRAWNIONY rule, whose window, threshold, and formula are owned by the PU model (Council-tunable constants in the configuration reference). Because eligibility is PU-derived data, the route's capability is pu.read (in the narrator and radny bundles); a Gracz self-checks via pu.read.own, which forces the verdict to their own Gracz. The roll is keyed by the owning Gracz's display name (resolved through @należy_do); ?gracz= filters by that name and ?asOf= sets the election date.

The slash-command surface

The Discord/Margonem bot exposes daemon features as slash commands for operators and players — each command is a thin call carrying the user's session token, and the capability check decides what runs (an unlinked Discord identity gets nothing). Four bands, plus the Radny's /uprawnienia (governance.read) and /uprawnienia ustaw … (governance.write):

Band Required capability Examples
Player self-service reads pu.read.own, currency.read.own /pu (my timeline), /wybory (am I eligible?)
Shared lore queries entity.read /encja Opat Perrin, /postać Lord Tussal, /lokacja Thuzal
Narrator session/PU actions session.write, pu.award /sesja zamknij …, /pu przyznaj
Player declarations player.declare.own /deklaracja (reserved — no daemon route yet)

Cmdlet surface

Invoke-NerthusMargonemAuth, Connect-NerthusIdentity, Get-NerthusWhoami, Get-NerthusIdentity, Get-NerthusCapability, Grant-NerthusCapability, Revoke-NerthusCapability, New-NerthusToken, Get-NerthusToken, Remove-NerthusToken, Get-NerthusNarratorPermission, Set-NerthusNarratorPermission, Get-NerthusElectionEligibility.

Routes, parameters, envelopes, and capabilities: the API reference. POST /auth/discord has no cmdlet wrapper — the bot calls it directly.

Examples

Authenticate a player from their Margonem add-on and inspect who they are:

# The add-on already obtained the signed Margonem envelope:
$tok = Invoke-NerthusMargonemAuth -Payload $signedEnvelopeJson
#  → bound to @margonemid 9826541 (Gracz "Eraster"), TTL 4 h

Get-NerthusWhoami -Token $tok.token
#  kind         : session
#  margonemId   : 9826541
#  gracz        : Eraster
#  capabilities : {entity.read, player.read, player.write.own, …}  # the seeded Gracz bundle

Link a Narrator's Discord, grant a one-off capability, and mint a dashboard token:

Connect-NerthusIdentity -DiscordId 184442xxxxxxxxxx -MargonemId 9826541
Grant-NerthusCapability -MargonemId 9826541 -Capability 'governance.write'

$t = New-NerthusToken -Name 'dashboard' -Roles narrator
$t.token          # the raw value — shown exactly once
Get-NerthusToken  # lists SHA-256 ids + name/kind/expiry, never values
Remove-NerthusToken -Id $listedId

Read a narrator's Uprawnienia and raise their Magia cap:

Get-NerthusNarratorPermission -Id 9826541
#  nick: Eraster  ranga: Narrator (Radny)  magia: do 3/5  walki_pvp: do 4 graczy

Set-NerthusNarratorPermission -Id 9826541 -Fields @{ Magia = 'do 4/5' }
#  → PATCH /governance/permissions/9826541 — the row is upserted and audited

# Routing query: who may run a level-4 magic session?
Get-NerthusNarratorPermission |
    Where-Object { $_.magia -in @('TAK','do 4/5') -and $_.ranga -ne 'Narrator nieaktywny' }

Generate the voting roll for a Rada election:

Get-NerthusElectionEligibility -AsOf 2026-07-01
#  Gracz    Window            PuScore  ActiveMonths  Total  Threshold  Status
#  Eraster  2026-01:2026-07      1,85             5   6,85        3,0  UPRAWNIONY
#  Anward   2026-01:2026-07      0,40             2   2,40        3,0  NIEUPRAWNIONY

Audit, privacy and failure responses

The audit log (.nerthus/log/audit.jsonl) records what changed — the operation, the entity, and the before/after values — and who did it: the actor field carries the resolved principal (machine, gracz:<margonem id> for an identity-bound session token, token:<name> for a named token), so a bot-routed write is attributed to the caller's Margonem ID. It never records the Margonem payload, the RSA signature, any raw token, or the client IP; token files are SHA-256-named and value-free. The audit stream itself is owned by the logs model.

Failure Behaviour
Missing bearer on a protected route 401 + WWW-Authenticate: Bearer, body error: Unauthorized
Invalid or expired token (any path) 401 + WWW-Authenticate: Bearer error="invalid_token" — indistinguishable by design
Margonem signature invalid 401, error: external_verification_failed (payload never logged)
Stale ts (> freshness window) 401, error: stale_payload
No Gracz carries the Margonem ID (either sign-in flow, or linking) 404, error: no_gracz_for_margonem_id
The Margonem ID matches multiple Gracze 409, error: ambiguous_margonem_id + the matches
Discord id_token invalid 401, error: external_verification_failed
Discord id not linked 403, error: discord_not_linked — no token minted
Valid identity, missing capability 403, the required capability named
Own-scoped caller, foreign subject 403 with an own-scope detail
Any mutation in read-only mode or on index-format drift 403, SchemaTooOld (even admin.all)

Open questions / decisions

  • Storage location — decided. Identity links, grants, and governance live in gitignored .nerthus/state — not the lore repository, not chmura-rady.
  • Data first, not hard enforcement — decided. Uprawnienia ceilings are routing data; parameterized-capability projection waits for full ACL coverage.
  • No sprawdzajka subsystem — decided. Advancement is a freeform note; the tool records that a character advanced, never adjudicates it.
  • Sprawozdania & Sprawy Graczy — out of scope. Human-run in Discord/chmura-rady; if added later they get their own state namespace.
  • Uprawnienia history — deferred. Rows are upserted in place (zmieniono only); date-ranged permission history is not modelled yet.
  • Names never persisted — decided. Link and session-token records hold ids only; the Gracz name is always the index's live answer, and legacy records with a stored name are ignored.
  • Per-identity audit attribution — decided. Every audit actor is the resolved principal (machine / gracz:<margonem id> / token:<name>).
  • GET /identity/{id} depth — deferred. Accepts Margonem id / Gracz name and reports the seeded Gracz bundle; Discord-id lookup and effective-grant reporting are not implemented (whoami is the effective view).
  • Per-Person role binding — deferred. Session tokens always carry the gracz role; narrator/radny powers attach via per-Person grants or named tokens.
  • /deklaracja — deferred. player.declare.own is seeded; no route consumes it.
  • Bot session-token acquisition — recorded, deferred. Today the bot obtains a user's session token via the Discord OIDC flow. A trusted-bot exchange route (the bot's named token asserts an interaction-verified Discord id and receives the linked user's session token) is a recorded option; it makes the bot a trusted identity asserter, so it waits until the bot is standing infrastructure.
  • Walki PvP: do N graczy parsing — deferred. The cell is stored verbatim; numeric-cap parsing for routing automation is not implemented.

See also