Skip to content

Permissions and identity

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 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 — or, for automation, holds a long-lived, committed API key bound to the same identity and valid on every fleet host. 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)
API key A long-lived nak_ bearer granted as @klucz_api in the contributor store or the moderators roster; identity-bound, valid on every fleet host
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 / Koordynator / IT / Bot / MC / SMC)
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.

Two consequences of that separation are easy to expect the other way round, so they are stated here rather than discovered:

  • A player does not read sessions, including the evenings they were at. Presence at a table is a fact about identity; reading the write-up is a capability, and the write-up carries what the narrator told the table and what was withheld from it. session.read is not in the gracz bundle, and it gates every door onto those bytes — the /sessions routes, the session search tier, and the session blocks inside a lore file.
  • A chat moderator is not a lore reader. The mc and smc bundles carry no entity.read. Somebody who moderates chat and narrates reads the lore through the narrator role, because an effective set is the union of the roles held — which is the mechanism that makes narrow bundles workable rather than obstructive.

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. The API key is the third path into the layer, needing no interactive proof because the committed grant already binds the identity.

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)

Discord sign-in is off until a host names the OAuth application it accepts tokens for. integrations.discord.client_id is that name, and the audience every id_token is checked against; Discord signs each registered application's tokens from one key set, so the audience is what binds a token to this deployment. While the key is empty the route mints nothing and answers 503 discord_auth_disabled.

Given an audience, the daemon verifies the id_token (a compact RS256 JWS) against Discord's published JWKS (fetched from integrations.discord.jwks_url, cached at cache/discord-jwks.json) and against integrations.discord.issuer, 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 /zamknij-sesję from Discord and have the action attributed to their Gracz.

Why Margonem ID is authoritative

A Margonem profile id is stable for an account, 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.

Stable for an account is not the same as permanent for a person. Somebody who abandons their Margonem account and plays on a new one gets a new profile id, and the store says so in the ordinary temporal grammar — the old id's range is closed, the new id opens on its own line:

### Stefan
- @margonemid: 100002 (:2026-07)
- @margonemid: 400002 (2026-07:)
- @rola: gracz

Everything then follows from which question is being asked:

  • Live decisions — the Margonem and Discord logins, token minting, the Gracz projection, the store's role and @dostęp fold — resolve the id as of now. A closed id stops matching on its date, so an account the person no longer controls (sold, lost, taken) stops being a way in. This is the security half, and no path may widen it to "any id the tag ever carried".
  • History — the private state the daemon wrote under an id since retired (capability grants, identity links, governance rows) and every past audit line — is read across every id the person held. Nothing is migrated and no log is ever rewritten: a line reading gracz:100002 stays true for the instant it records.

The consequence worth stating plainly: a revoke recorded against the old id keeps applying after the account change. Losing it would hand back a capability the Rada took away, with nothing in any log to say it happened.

There is no rebind endpoint and no state migration. The change is a commit to nerthus.contributors.md, reviewed like any other lore change — see people and roles.

The API key (@klucz_api) — committed and fleet-wide

The contributor store may grant a person a long-lived API key: a @klucz_api tag in their block carrying the SHA-256 of a nak_ bearer. Whoever presents the raw value resolves as that person — a Layer-B principal like any session-authenticated caller, with no interactive flow. The moderators roster (nerthus.moderators.md) is a second granting store: a @klucz_api there resolves a chat moderator who is in no other roster, with their mc/smc bundle and no gracz; a hash claimed in both stores is refused in both.

Grant. The key is minted by the daemon: POST /api-keys (capability apikey.manage; its .own narrowing mints for the caller's own id only — a Bot's self-rotation) generates the raw value — nak_ + 64 lowercase hex — into a private one-time stash and returns the ready-to-paste - @klucz_api: sha256:<hash> line plus a claim path. The raw value is retrievable exactly once, from GET /api-keys/claim/<sha> — the entry destroys itself on the first read, so the minter hands the claimant a link, never the key. The target is a person (margonemId) or a whole ## Role block (rola) — a role-held key wields the role's live caps with no person behind it (no Gracz, every .own scope refuses, audit says api:rola:<label>). A caller may mint only for a target whose effective capabilities are the same or less than the caller's own — a stronger target is a 403. The operator pastes the line into the owner's block, commits, and the fleet's ordinary sync distributes the grant; the store keeps only the hash, and no daemon write ever touches it.

Use. Authorization: Bearer nak_… on any host: the daemon hashes the bearer with the same SHA-256 id every token record uses, matches the hash against the store, and resolves the principal a session token would — the block's Margonem ID, the live-resolved Gracz, and capabilities recomputed on every request through the capability ACL below. Verification is a pure read — no file is written — so a read-only replica authenticates an API key exactly like the primary. That is the property session tokens lack: their records are private, host-local files under runtime/tokens/, meaningless on every other host.

Propagation. A grant or a revocation (deleting the line) takes effect on each host at its next converge — ≤5 minutes on the dev primary, ≤15 minutes on replicas. Until a host converges, its last-converged copy of the store still validates the key: a deleted line keeps authenticating on that host for up to one sync interval, and there is no cross-host revocation call. An operator containing a leaked key plans against those worst-case numbers.

Validity window. The grant line takes the standard entity temporal range — - @klucz_api: sha256:<hash> (2026-08-01:2027-01-31), either side open — outside which the key stops resolving; the To bound is compared in UTC against day-end. Absent range means non-expiring, matching named tokens' TtlSeconds 0. The exact value grammar is the contributor store reference's.

Main branch only. A host whose repo checkout is off its sync branch — a side branch, a detached HEAD — refuses every nak_ bearer until the checkout returns. A key answers only to the fleet's converged truth, never to a work-in-progress ref.

Whoami and audit. GET /auth/whoami reports kind: api with the resolved margonemId, gracz, and effective capabilities; every audited write is attributed api:<margonem id> — distinguishable from the same person's interactive gracz:<margonem id> session.

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), and sync.run follows the same precedent — it resolves through admin.all or a per-Person grant. Reading sync state is the exception, and the seeded IT bundle carries sync.read. 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. That file is the single source of truth for both a role's capabilities and its authored labels — each row carries role, its aliases (the collectives a @rola may spell), and capabilities — so a Radny adds a role, or relabels one, in committed config, never in code; the in-code bundle map is only the fallback for a repo with no roles.json. The committed contributor store (nerthus.contributors.md) now holds the authoritative role definitions and the role Discord channels: a ## Role block's explicit @dostęp overrides that role's bundle and its @discord names the role's shared channel, while its ## Osoby blocks bind each person to any number of roles, to personal capabilities, and to their own purposed @discord channels — so roles.json is only a lower-priority fallback seed. The seeded bundles, their differences, and the file's shape are in the capabilities reference and the data-tables reference. The read-only IT role is one such seeded bundle: it holds no write or admin capability and exists to observe the fleet and deliver technical notices.

A Person's effective set = their gracz role bundle, plus the contributor store's role and personal capabilities for their Margonem ID (the fold below), plus the moderators roster's mc/smc bundles for every @rola whose range covers now, 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 an edit to the store, a grant, or a revoke takes effect on live tokens immediately. Every externally-minted session token carries the gracz role — a Narrator's or Radny's elevation comes from their @rola lines in the contributor store, and per-Person grants or a named token minted with -Roles narrator still stack on top.

Several roles, one person: the fold order

A person holds as many @rola lines as they have capacities — somebody who plays and narrates holds both — and this page owns the order those resolve in. It is two steps, and the order is the contract:

  1. Union every role the person currently holds. Each label contributes its caps: the ## Role block's explicit @dostęp when it has one, else the baked bundle. A line whose (od:do) range has closed contributes nothing, so a role is retired by closing its range.
  2. Then apply the personal @dostęp — once, last, over that union. Each personal capability removes every member of the set sharing its <resource>.<action> stem and adds itself.
flowchart LR
    T["Session token<br/>(gracz role)"] --> U["∪ caps of EVERY @rola held"]
    U --> O["personal @dostęp<br/>ONCE, LAST, over the union"]
    O --> M["∪ moderators-roster<br/>mc/smc bundles"]
    M --> G["∪ runtime grants"]
    G --> R["− runtime revokes<br/>(revoke wins)"]
    R --> E["Effective capabilities"]

After the personal narrowing, the moderators roster's mc/smc bundles union in — before the runtime grants and revokes, so a revoke still wins over an mc bundle.

Step 2 running after step 1, rather than inside it, is what makes a personal narrowing hold. A person holding narratorzy (which grants session.write) and gracz, with a personal @dostęp: session.write.own, resolves to session.write.own — the narrowing survives. Applied per role instead, narratorzy's wide grant would re-enter the set after the narrowing and silently widen it back.

One consequence follows from every player holding @rola: gracz: a ## Role block for gracz carrying @dostęp replaces the baked player bundle for everybody. That is a deliberate lever — the Rada retunes the baseline in committed lore, in one place — and not a side effect.

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.
  • An API key has no record here: its committed @klucz_api line stores only the hash, and GET /tokens does not list it — the committed store is its own listing (contributor store). This directory inventories only host-local, runtime-minted tokens.

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 Fabularna Świata Nerthus (the Rada) elected yearly, a Namiestnik with standing oversight (bezkadencyjny — the office has no fixed term), and Narratorzy who run sessions under permissions the Rada 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 Rada'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.

The table

Uprawnienia is the canonical statement of what each Narrator is allowed to run. It mirrors the Rada'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 (Rada-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.close, pu.award /zamknij-sesję …, /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-NerthusApiKey, Receive-NerthusApiKey, 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   # -Roles frozen into the record at mint; no -TtlSeconds → never expires
$t.token          # the raw value — shown exactly once
Get-NerthusToken  # lists SHA-256 ids + name/kind/expiry, never values
Remove-NerthusToken -Id $listedId

Grant the same person a fleet-wide API key — minted by the daemon, claimed once — and use it:

$k = New-NerthusApiKey -MargonemId 9826541 -To 2027-01-31
$k.line       #  - @klucz_api: sha256:ed7d29629018307c7f6325f8d3419fa5187400eda93ef8ec217174eb1eb2c7ad (:2027-01-31)
$k.claimPath  #  /api-keys/claim/ed7d29629018307c7f6325f8d3419fa5187400eda93ef8ec217174eb1eb2c7ad

# The owner (or you, on their behalf) claims the raw value — ONCE; a second claim is 404:
$raw = (Receive-NerthusApiKey -Sha $k.hash).token
#  nak_ed9183e8ce1e4993a7d8235597f326af3a7133beac3d4d38a3b660ea061c31d2

# Paste the line into the person's block, commit; after each host converges:
Get-NerthusWhoami -Token $raw
#  kind         : api
#  margonemId   : 9826541
#  gracz        : Eraster
#  capabilities : {entity.read, player.read, player.write.own, …}  # the live fold, recomputed per request

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, api:<margonem id> for an API key, token:<name> for a named token), so a bot-routed write is attributed to the caller's Margonem ID and a key-driven write stays distinguishable from the same person's interactive session. 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 sign-in unconfigured (client_id empty) 503, error: discord_auth_disabled — no token minted
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)

A bad API key adds no row: an unknown, malformed, or expired nak_ bearer is the same "Invalid or expired token" 401, indistinguishable by design — no response hints whether a hash exists or has expired.

Why it works this way

  • Storage location. Identity links, grants, and governance live in gitignored .nerthus/state — kept out of the lore repository and out of chmura-rady.
  • Ceilings are data, not enforcement. Uprawnienia ceilings are routing data; parameterized-capability projection waits on full ACL coverage.
  • No sprawdzajka subsystem. 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 and chmura-rady.
  • Names never persisted. Link and session-token records hold ids only; the Gracz name is always the index's live answer, and a legacy record with a stored name is ignored.
  • Per-identity audit attribution. Every audit actor is the resolved principal (machine / gracz:<margonem id> / api:<margonem id> / token:<name>).
  • Per-Person role binding. Session tokens carry the gracz role; a narrator's or radny's extra powers attach via per-Person grants or named tokens.

See also