Auth, identity & tokens¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Every request carries a bearer token that resolves to a principal with a capability
set. This page covers the two public sign-in routes (/auth/margonem, /auth/discord),
the Discord ⇄ Margonem identity link, the whoami/identity lookups, the capability ACL
(grant/revoke), and the named service tokens an operator mints. An API key also
authenticates here: a nak_ bearer granted as a @klucz_api: hash in the committed
contributor store or in the moderator roster, valid on every fleet host. The identity examples
use the fixture Gracz Stefan (Margonem id 100001, character Eraster) from the
canonical session; the model is spelled out in
roles & permissions.
/auth/margonem and /auth/discord are public (no token). whoami/identity need
identity.read, /capabilities needs capability.read, grant/revoke need
capability.admin, and tokens need token.manage — the admin token carries all of them.
Every example runs against a live daemon — see how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| POST | /auth/margonem |
Invoke-NerthusMargonemAuth |
— | — |
| POST | /auth/margonem/challenge |
— | — | — |
| POST | /auth/discord |
— (Discord OIDC sign-in) | — | — |
| POST | /auth/discord/challenge |
— | — | — |
| GET | /auth/discord/config |
— | — | — |
| POST | /auth/link |
Connect-NerthusIdentity |
identity.link |
✓ |
| POST | /workflows/register-by-discord |
— (self-registration from Discord) | discord.dispatch |
✓ |
| POST | /workflows/whoami-by-discord |
— (the Discord self-report) | discord.dispatch |
— |
| POST | /workflows/people-by-discord |
— (the assign picker) | discord.dispatch |
— |
| POST | /workflows/assign-by-discord |
— (the Rada assigns an account) | discord.dispatch |
✓ |
| GET | /auth/whoami |
Get-NerthusWhoami |
identity.read |
— |
| GET | /identity/{id} |
Get-NerthusIdentity |
identity.read |
— |
| GET | /capabilities |
Get-NerthusCapability (the caller's resolved set) |
capability.read |
— |
| POST | /capabilities/grant |
Grant-NerthusCapability |
capability.admin |
✓ |
| POST | /capabilities/revoke |
Revoke-NerthusCapability |
capability.admin |
✓ |
| POST | /tokens |
New-NerthusToken |
token.manage |
✓ |
| GET | /tokens |
Get-NerthusToken |
token.manage |
— |
| DELETE | /tokens/{id} |
Remove-NerthusToken |
token.manage |
✓ |
| POST | /api-keys |
New-NerthusApiKey |
apikey.manage |
✓ |
| GET | /api-keys/claim/{sha} |
Receive-NerthusApiKey |
— | — |
Cap is the required capability (— = public, no token); Write (✓) marks routes that pass the write gate; a — in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.
The two /auth/* sign-in routes are public and mint nothing until they resolve a Margonem id to a Gracz; they write only private token records, so they stay operable in read-only mode. Token, identity, and ACL semantics: Permissions.
Sign in with a Margonem payload¶
POST /v1/api/auth/margonem verifies an RSA-signed { user_id, token, ts, signatureBase64 }
payload, resolves the Margonem id to a Gracz, and mints a session token. The signature
cannot be forged in a doc example, so this shows the rejection: an unsigned body is 401
with the failure reason in error.
A success answers { token, identity, capabilities, expires }, and on a host that can mint
one, fleetToken and fleetTokenExpires beside them. /auth/discord answers the same shape,
including the fleet token: the two paths resolve the same principal, and a player who signed in
with Discord having worse read availability than one who used the addon was an asymmetry with no
argument behind it. The two differ in where they can be
used, not in what they grant:
token(sess_…) is recorded in the answering host's ownruntime/tokens, so that host alone can resolve it. It is the long session.fleetToken(fsess_…) is signed rather than recorded: any host holding the commit verifies it against the public key published in the committed tree, which is what lets a read be answered away from the host that signed you in. Short-lived on purpose — refresh it against the apex — because a bearer nothing looks up cannot be withdrawn one at a time. Capabilities are not carried in it; they are resolved per request from the identity's roles, so a grant or a revoke takes effect while the token is outstanding. Raising theepochin the published document refuses every outstanding one at once.
A host that cannot mint — a read-only replica — omits both fields and still verifies tokens minted elsewhere.
Response 401:
The login challenge¶
POST /v1/api/auth/margonem/challenge answers { challenge, expires, ttlSeconds }. The addon
passes challenge as the token in its /account/validate call, and the daemon burns it when the
signed envelope comes back to POST /auth/margonem above.
Without it, the signature proves the account and the 300-second timestamp skew bounds replay to five minutes — inside which a payload captured for another user can be presented by whoever holds it. The challenge takes that window to zero.
integrations.margonem.require_challenge ships false, so the window is still open. Nothing
sends a challenge yet: the dashboard's fetchEnvelope posts to /account/validate with no token
at all, and the in-game addon is not in this repository. Turn it on once the clients that matter
ask for a challenge; it is one config line and no further code.
The alphabet is fixed at [A-Za-z0-9] because the signed string is user_id + "+" + token + "+" +
ts and a + inside the token is a boundary. Verification reconstructs that string and never
parses it, so this is a second line of defence rather than the only one.
Anonymous, and bounded: 512 outstanding challenges, 429 challenge_flood past that, swept by
expiry on every mint.
There is no api:example here on purpose — a replay would mint a real nonce on whatever daemon CI
points at.
Register yourself from Discord¶
POST /v1/api/workflows/register-by-discord is the delegate behind the /nerthus-rejestracja
slash command. The Worker verifies Discord's Ed25519 signature and forwards the invoker's Discord
id, the guild the command was typed in, and the Margonem id the player typed. The daemon binds the
two and hands back the sentence the Worker announces in that channel.
Nothing here verifies that the caller owns the Margonem id they typed. An interaction proves a
Discord id and nothing else, and no tag in the lore store bridges the two. Publicity is the control
— the announcement is read by the player, the Rada and the Namiestnik — and it is a detection
control: by the time the message is read, the binding has happened. The Rada's remedy is
/auth/link, which stays theirs.
Three rules bound what that costs:
- the privileged are not self-registrable. A Margonem id resolving to anything beyond the
graczbundle is refused, so a registration can never hand out access: the ids it accepts granted nothing to begin with. Narrators, Radni and moderators are linked by hand. The predicate is the capability resolver itself rather than a list of roles, so elevation reached through the moderators roster, through a runtime grant, or through a derived capability is caught too. - the guild gate. A direct message, or another server, is refused. A host that has not set
integrations.discord.guild_idrefuses everything rather than accepting from anywhere. - a claimed id is displaced, and the displacement is announced. An id already registered to a different Discord account is taken over, and the announcement names the Discord account that lost it. Operator ruling 2026-09-01, against a recommendation to refuse: the consequence is that any guild member can take over an unprivileged linked account by typing its id, and the message in the channel is the whole of what stands in the way — the same bet the ruling makes about the self-declared id itself. The privileged refusal above is what keeps it away from every account that holds any access.
Two different displacements, and the answer keeps them apart because they are not the same event.
replaces is a person moving their own Discord account and losing their previous binding;
displaces is this Discord account taking a Margonem id off somebody else. Both are named in the
announcement, and both land in the audit stream under identity.register.
| Status | When |
|---|---|
400 |
discordId or margonemId missing |
503 registration_disabled |
integrations.discord.guild_id is empty on this host |
403 registration_needs_guild |
no guildId — the command was typed in a direct message |
403 wrong_guild |
typed in a server that is not this deployment's |
404 no_gracz_for_margonem_id |
no Gracz block carries that id |
409 ambiguous_margonem_id |
two Gracz blocks carry it, listed in matches — a data defect |
403 registration_reserved |
the id resolves above the gracz floor; the Rada links it |
| — | an id another Discord account holds is displaced, not refused; displaces names the account that lost it |
200 |
status: registered, or already_linked when the same account repeats the command |
?dryRun=true answers wouldRegister and the same announce sentence, and writes nothing.
The fixture host names no guild, so a well-formed request meets the switch rather than the gate:
Response 503:
The Rada assigns an account¶
POST /v1/api/workflows/assign-by-discord is the delegate behind /nerthus-rejestracja-przypisz.
A Rada member binds somebody else's Discord account to a Margonem id — including an id that
/nerthus-rejestracja refuses, which is the reason it exists.
Authority is the Discord guild role, and this is the one route where that is true. Every other
Discord delegate resolves rights from the invoker's linked player. This one cannot: the person who
needs it is precisely the one with no link yet, so requiring a link would require the thing the
command creates. The authorised roles are named in nerthus.contributors.md, on the ## Role
blocks, as @discord_rola: <guild role name>.
The consequence, stated rather than left to be inferred: whoever administers Discord roles administers Nerthus identity. Operator ruling 2026-09-02.
A name rather than an id, because the name is what the Rada administers and what a reviewer of
that file can read. The daemon resolves it against the live guild the way it already resolves a
channel name, cached under integrations.discord.role_cache_ttl_min with one forced refetch on a
miss. That choice has a cost and the refusals are shaped around it:
reason |
What happened |
|---|---|
roles_unreadable |
the guild's roles could not be fetched — no bot token, or Discord did not answer. Never reported as "you are not authorised": an empty allowlist authorises nobody and would read the same |
not_authorised |
the invoker holds no declared role. The answer carries Declared, the names lore expects, so a renamed role is distinguishable from a person who simply lacks it |
no_role_declared |
no ## Role block carries @discord_rola at all |
invoker_has_no_roles |
the interaction carried no roles |
replaces and displaces mean what they do on the self-registration path, and the announcement
names the actor and the role that authorised them — an act with a target needs an author. The
audit line records identity.assign with actor, target, role and guild role.
The assign picker¶
POST /v1/api/workflows/people-by-discord answers up to 25 people matching a name or id prefix,
each with linked. It exists apart from GET /people for two reasons: Discord gives an
autocomplete about three seconds and no way to defer, so this is synchronous and capped; and
/people costs player.read and returns the whole roster, where the Worker's token holds
discord.dispatch alone. Widening that token to fill a dropdown would be the wrong trade.
It lists all people, not only the unlinked: re-linking after an account change is a real case, and a picker that hid everyone already bound could not serve it.
One match arrives as an object, not a list
ConvertTo-Json unwraps a single-element array, and every list route in this daemon builds its
body as items = @(...) — so one result is {"count":1,"items":{…}} while two are
{"count":2,"items":[…]}. This is not particular to this route: GET /routes publishes
params as a bare string for every route declaring exactly one.
A client must normalise before iterating. Left unhandled it is worse than it looks — the narrow prefix somebody types when they already know who they want is exactly the query that returns one row, so the failure lands on the most confident user and shows an empty list rather than an error.
What the daemon knows about a Discord invoker¶
POST /v1/api/workflows/whoami-by-discord is the delegate behind /nerthus-kim-jestem, and it is
the only Discord read that answers a caller who is not linked.
That is the point of it. Every other Discord delegate resolves the invoker through the identity
link and refuses without one; /auth/whoami needs a token, which needs a sign-in that worked; and
/identity/{id} needs identity.read. So before this route, a person who could not get in could
ask this estate nothing at all about themselves, and the only self-service fact in the system was
the discordId echoed back in a 403 discord_not_linked body.
It is own-scoped by construction: the Discord id arrives from a signature-verified interaction and there is no parameter with which to name anybody else.
{ "discordId": "…", "linked": true, "margonemId": "100001", "gracz": "Stefan",
"graczResolves": true, "guildMatches": true, "signInEnabled": false,
"registrationEnabled": true, "capabilityCount": 7 }
linkedagainstgraczResolvesseparates "I am not linked" from "my link points at a Margonem id noGraczblock carries". Different fixes, and the second one is the Rada's.guildMatchesisnullwhere the host names no guild,falsewhere the command came from somewhere else — which is why a player who typed it in the wrong place learns that here rather than from a registration that mysteriously refuses.signInEnabledandregistrationEnabledreport the two config switches, so "it is broken" and "it is off on this host" stop looking the same.capabilityCount, never the list: the count answers did my grant land, which is the diagnostic question. The list is an inventory of what a named person may do, and it would travel wherever the answer travels.
The command replies ephemerally, deliberately: a registration is public because publicity is its control, while a diagnostic changes nothing and publishing it would leak one player's identity mapping to the channel for no gain.
Sign in with a Discord id_token¶
POST /v1/api/auth/discord verifies a Discord OIDC id_token (RS256), resolves the linked
Margonem id, and mints a session token.
Discord is an OpenID provider, and it publishes the proof. Its OAuth topic page describes no
openid scope and no id_token, which is why this route looked speculative for as long as it did —
but https://discord.com/.well-known/openid-configuration answers 200 and names exactly what this
daemon already assumed (measured 2026-09-01):
{ "issuer": "https://discord.com",
"authorization_endpoint": "https://discord.com/api/oauth2/authorize",
"token_endpoint": "https://discord.com/api/oauth2/token",
"jwks_uri": "https://discord.com/api/oauth2/keys",
"response_types_supported": ["code", "token"],
"id_token_signing_alg_values_supported": ["RS256"] }
Both defaults in integrations.discord — the issuer and the JWKS URL — are that document's own
values, and RS256 is the algorithm /auth/discord verifies. The openid scope is accepted by the
authorize endpoint too: an unknown scope answers 400 {"scope": ["0"]}, and openid identify
answers 302 to the consent screen. A body without id_token is rejected 400 before
any verification.
Sign-in stays off until a host names an audience. integrations.discord.client_id is the
OAuth application whose id_tokens this host accepts, and every token's aud is checked
against it. The key defaults to empty, and while it is empty the route answers
503 discord_auth_disabled and verifies nothing. Discord signs for every registered
application from one key set, so the audience is what binds a token to this deployment
rather than to another app on the same platform.
What a browser needs to start one¶
GET /v1/api/auth/discord/config answers { clientId, enabled, issuer } — the OAuth application
this host accepts, and whether it will finish a sign-in at all. Anonymous, because a page
that has not signed in yet is exactly the caller that needs it, and because the client id is the
audience rather than a credential: it is public in every authorize URL a browser builds.
It exists because the alternative failed in a way neither half could see. The dashboard baked the id in at build time while the estate carries it as a fleet setting — one value for every host — so the deployed bundle held the entire sign-in flow and an empty id. No button rendered, on hosts that were correctly configured, and nothing was wrong with either side.
Asking at runtime keeps one setting in one place. A client already resolves which daemon it talks
to per load; resolving that daemon's application the same way means one build serves dev and
production, and a host naming no application answers enabled: false — the same dark state
/auth/discord reports, read from the same key, so the two cannot disagree.
The sign-in nonce¶
POST /v1/api/auth/discord/challenge mints a single-use [A-Za-z0-9]{32} nonce. The client puts
it on the authorize URL as nonce=, Discord echoes it in the id_token's claims, and
/auth/discord burns it. A token whose nonce is not outstanding is 401 with
reason: nonce_not_outstanding.
Without it a leaked id_token mints sessions until its own exp. The Margonem challenge beside it
ships off by default because turning it on would refuse every login that already works; Discord
sign-in has no client predating the check, so integrations.discord.require_nonce defaults on.
nonce_ttl_s (600) and nonce_max_outstanding (512) bound it — the mint is anonymous by
necessity, so it is capped as well as swept.
Two properties are load-bearing and mirror the Margonem challenge. The burn is a rename, so two concurrent redemptions of one nonce cannot both succeed; and expiry is judged after the burn, so a nonce presented late is spent either way. The nonce is read after the signature has been judged, so a caller cannot use this route to discover which nonces are outstanding.
Nonces are stored apart from Margonem challenges: one minted for either provider never redeems against the other.
Two keys sit beside it. integrations.discord.issuer is the issuer each token must name,
https://discord.com by default. integrations.discord.jwks_cache_ttl_min bounds how long a
fetched key set stays cached on disk, 1440 minutes by default; a kid the cached set does not
hold triggers one refetch and one re-verification, so a rotated signing key recovers inside
the request. All three live in Configuration.
| Status | When |
|---|---|
400 id_token required |
the body carries neither id_token nor idToken |
503 discord_auth_disabled |
integrations.discord.client_id is empty on this host |
401 external_verification_failed |
verification refused the token; reason names which check — malformed_jwt, kid_not_found, signature_invalid, expired, issuer_mismatch, audience_mismatch |
403 discord_not_linked |
the token's sub reaches no identity link, echoed back as discordId |
404 no_gracz_for_margonem_id |
the link names a Margonem id no Gracz block carries |
409 ambiguous_margonem_id |
two Gracz blocks carry that id, listed in matches |
Response 400:
The fixture host leaves client_id empty, so a well-formed request meets the switch rather
than the verifier:
Response 503:
{ "error": "discord_auth_disabled",
"detail": "integrations.discord.client_id is not configured; Discord sign-in is off on this host" }
Link a Discord account to a Margonem id¶
POST /v1/api/auth/link binds a Discord id to a Margonem id (one Discord per id; a re-link
revokes the prior). The link stores ids only — the Gracz name is resolved live, so it never
goes stale on a rename. Here Stefan's 100001 links cleanly.
Response 200:
{ "margonem_id": "100001", "discord_id": "555000111222", "gracz": "Stefan", "linked_via": "margonem", "linked_at": "2026-07-01T12:00:00Z" }
Rejected: link without ids¶
A body missing both ids is 400, nothing written.
Response 400:
Who am I¶
GET /v1/api/auth/whoami reflects the caller's resolved principal: its kind,
margonemId, gracz, and effective capabilities. The admin token resolves to the
loopback machine principal with admin.all.
Response 200:
Rejected: no token¶
whoami needs identity.read, so an anonymous request is 401 — authentication precedes
everything.
Response 401:
Authenticate with an API key¶
A nak_ bearer needs no sign-in flow: the daemon hashes the presented value with the
same SHA-256 that names every token record, finds the block that carries that hash, and
resolves the principal a session token would — kind api, the block's Margonem id, the
live-resolved Gracz, capabilities recomputed on this request. Nothing is minted and
nothing is written, so a read-only replica authenticates the key exactly like the
primary. The nak_… value below is the fixture store's public doc key (granted to
Stefan's block) — a real key never appears in a page.
A hash held in the moderator roster (nerthus.moderators.md)
authenticates the same way, and it is the only way in for a moderator who is in no other
roster — the margonem exchange refuses them with no_gracz_for_margonem_id. The
resolved capabilities are the holder's mc/smc bundle with no gracz bundle beside
it, since the key is the moderator's function rather than a player. The capability fold
reads @rola in both files, so a person with a block in each gets the union of what
each grants, whichever file the key sits in.
Response 200 (trimmed):
{ "kind": "api", "margonemId": "100001", "gracz": "Stefan", "capabilities": ["entity.read", "player.read"] }
Rejected: unknown, expired, or revoked API key¶
A well-formed but ungranted nak_ bearer gets the byte-identical envelope every invalid
token gets: 401 { "error": "Unauthorized" } with WWW-Authenticate: Bearer
error="invalid_token". That is deliberate — expired, revoked, and never-granted keys are
one indistinguishable case, so probing the daemon cannot learn which hashes exist.
Response 401:
Mint an API key¶
POST /v1/api/api-keys (capability apikey.manage; the .own narrowing may mint for
the caller's own margonemId only — never a role) generates the raw nak_… value
into a private one-time claim stash and returns the ready-to-paste @klucz_api store
line, the key's SHA-256, and the claim path — never the raw value. The target is
exactly one of margonemId (a person) or rola (a ## Role label; unknown label is
400, both or neither is 400). 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 403
with the exceeding ids listed. Optional from/to compose the line's (from:to)
validity range (the standard temporal grammar; a range the store would refuse is
400). The store itself stays hand-authored: the grant goes live when an operator
pastes the returned line into the owner's block — person or role — and commits.
Response 201 (trimmed):
{ "hash": "eaf2b520…", "line": "- @klucz_api: sha256:eaf2b520…", "margonemId": "100001", "claimPath": "/api-keys/claim/eaf2b520…" }
Claim a minted API key — once¶
GET /v1/api/api-keys/claim/{sha} is the only place the raw value ever appears.
The route is public (the 64-hex sha in the path is the shared secret) and one-shot:
the first request returns 200 { "token": "nak_…", "line": "…", "margonemId": "…" }
and destroys the stash entry; every later request — and any unknown, already-claimed,
or stale sha (entries expire unclaimed after one hour) — is the same
404 { "error": "api key not found" }. Hand the claimant the URL, not the key; an
empty first visit means someone else claimed it — delete the pasted line and re-mint.
Resolve an identity¶
GET /v1/api/identity/{id} resolves a Person by Margonem id (or Gracz name) from the model,
returning the Gracz, the Margonem id, and the role-derived capability bundle.
Response 200 (trimmed):
Rejected: unknown identity¶
An id that matches no Gracz is 404.
Response 404:
The caller's capabilities¶
GET /v1/api/capabilities returns the { count, capabilities } set of the caller's own
principal — the admin token holds just admin.all.
Response 200:
Preview a capability grant¶
POST /v1/api/capabilities/grant stacks one capability onto a Person's effective set (on
top of their role bundles). With ?dryRun=true it returns 200 { wouldGrant, margonemId }
and persists nothing.
Response 200:
Rejected: grant without fields¶
Omitting margonemId or capability is 400.
Response 400:
Preview a capability revoke¶
POST /v1/api/capabilities/revoke removes one capability from a Person's set — the mirror
of grant. ?dryRun=true returns 200 { wouldRevoke, margonemId } and writes nothing.
Response 200:
Mint a named service token¶
POST /v1/api/tokens issues a named token scoped to an explicit capability set. Beside
name (required) and capabilities, the body takes roles — each named role's live
bundle is resolved at mint time and baked into the record, so a later bundle change does
not follow the token — and ttlSeconds, where 0 (the default) means no expiry. A
roles-only body with no capabilities is valid. The raw tok_… value is returned once,
in this response — it is never stored (the record is keyed by its SHA-256), so it cannot
be recovered later.
Response 201:
List issued tokens¶
GET /v1/api/tokens returns the { count, items } envelope — each item is a token's id,
kind, name, and expiry, never its raw value. The ci-tool token minted above is present.
API keys never appear here: runtime records and committed grants are two ownership
models, and the store file is its own listing.
Response 200 (trimmed):
Revoke a token¶
DELETE /v1/api/tokens/{id} deletes the token record by its id. An id that matches no
record is 404.
Response 404: