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.readis not in the gracz bundle, and it gates every door onto those bytes — the/sessionsroutes, thesessionsearch tier, and the session blocks inside a lore file. - A chat moderator is not a lore reader. The
mcandsmcbundles carry noentity.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
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. 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:
- 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)¶
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:
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ępfold — 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:100002stays 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 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), 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:
- Union every role the person currently holds. Each label contributes its caps: the
## Roleblock's explicit@dostępwhen 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. - 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-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.- An API key has no record here: its committed
@klucz_apiline stores only the hash, andGET /tokensdoes 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/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 (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 ofchmura-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
actoris the resolved principal (machine/gracz:<margonem id>/api:<margonem id>/token:<name>). - Per-Person role binding. Session tokens carry the
graczrole; a narrator's or radny's extra powers attach via per-Person grants or named tokens.
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 - Contributor store — the committed source of functional-role rights and role Discord channels
- 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 — the
integrations.margonem.*andintegrations.discord.*keys both sign-in flows read - 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