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
nerthusdmints a random token and writes it to.nerthus/runtime/daemon.tokenwith mode0600(alongsidedaemon.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
401re-readsdaemon.tokenonce 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 allVerb-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:
- The add-on POSTs to Margonem's
https://public-api.margonem.pl/account/validatewith the player's cookies. Margonem returns a signed envelope:{ user_id, token, ts, validatedString, signatureBase64 }. - The add-on forwards that JSON verbatim to Nerthus:
POST /auth/margonemwith body{ "payload": "{…}" }. - The daemon reconstructs the signed string itself as
"{user_id}+{token}+{ts}"— the suppliedvalidatedStringfield is ignored; only the components are trusted. It decodessignatureBase64and verifies the signature against the cached public key using RSA-SHA256, PKCS#1 v1.5 padding. - The public key is fetched from
integrations.margonem.signing_key_urlon first use and cached on disk (cache/signing-key.pem); the cache refreshes aftersigning_key_cache_ttl_s, and a failed refetch keeps the last good key (defaults in the configuration reference). - Freshness gate:
abs(server_now − ts) ≤ auth_timestamp_skew_s(default 300 s) — a replayed stale payload is rejected (see failure responses). - The daemon resolves
user_idto a Gracz by matching the@margonemidtag — only Gracz-typed blocks match, because imported Mapa blocks carry@margonemidtoo (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. - 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 reportskind: 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 iswhoami.Get-NerthusCapability— the caller's own effective capability set; there is no person selector — auditing another Person's grants is acapability.adminconcern.
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-Rolesbundle is expanded into it);TtlSeconds 0means no expiry. GET /tokenslists{ Id, Kind, Name, Gracz, Expires }— hash ids, never values; theGraczcolumn is resolved live from each record'smargonem_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 fromruntime/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/5 ⇒
session.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, notchmura-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 (
zmienionoonly); 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
actoris 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 (whoamiis the effective view).- Per-Person role binding — deferred. Session tokens always carry the
graczrole; narrator/radny powers attach via per-Person grants or named tokens. /deklaracja— deferred.player.declare.ownis 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 graczyparsing — deferred. The cell is stored verbatim; numeric-cap parsing for routing automation is not implemented.
See also¶
- Roles introduction — the operator-facing view of who may do what
- Capabilities reference — the closed capability id list and the seeded role bundles
- Architecture — daemon topology,
.nerthus/layout, private state, hooks - API reference — routes, envelopes, middleware chain, the write gate, cmdlet conventions
- Player model — Gracz/Postać model,
@margonemid,@należy_do - PU model — the PU timeline and the eligibility formula consumed above
- Configuration reference —
integrations.margonem.*keys - Data-tables reference —
roles.jsonlocation and the seed-only-when-absent rule - Locations model — what
location.overrideactually gates - Logs model — audit streams and Discord delivery