Skip to content

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 own runtime/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 the epoch in 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.

POST /v1/api/auth/margonem
Content-Type: application/json

{ }
await fetch("https://evocation.nerthus.pl/v1/api/auth/margonem", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ user_id: "100001", token: "…", ts: 1751328000, signatureBase64: "…" }),
}).then((r) => r.json());

Response 401:

{ "error": "malformed" }

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.

POST /v1/api/auth/margonem/challenge
await fetch("https://evocation.nerthus.pl/v1/api/auth/margonem/challenge", {
  method: "POST",
}).then((r) => r.json());

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 gracz bundle 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_id refuses 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:

POST /v1/api/workflows/register-by-discord
Authorization: Bearer <token>
Content-Type: application/json

{ "discordId": "123456789012345678", "guildId": "987654321098765432", "margonemId": "100001" }
await fetch("https://evocation.nerthus.pl/v1/api/workflows/register-by-discord", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
  body: JSON.stringify({ discordId, guildId, margonemId }),
}).then((r) => r.json());

Response 503:

{ "error": "registration_disabled" }

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 }
  • linked against graczResolves separates "I am not linked" from "my link points at a Margonem id no Gracz block carries". Different fixes, and the second one is the Rada's.
  • guildMatches is null where the host names no guild, false where 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.
  • signInEnabled and registrationEnabled report 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
POST /v1/api/auth/discord
Content-Type: application/json

{ }
await fetch("https://evocation.nerthus.pl/v1/api/auth/discord", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ id_token: "<discord jwt>" }),
}).then((r) => r.json());

Response 400:

{ "error": "IdTokenRequired", "detail": "id_token required" }

The fixture host leaves client_id empty, so a well-formed request meets the switch rather than the verifier:

POST /v1/api/auth/discord
Content-Type: application/json

{ "id_token": "<discord jwt>" }
await fetch("https://evocation.nerthus.pl/v1/api/auth/discord", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ id_token: "<discord jwt>" }),
}).then((r) => r.json());

Response 503:

{ "error": "discord_auth_disabled",
  "detail": "integrations.discord.client_id is not configured; Discord sign-in is off on this host" }

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.

POST /v1/api/auth/link
Content-Type: application/json
Authorization: Bearer <token>

{ "discordId": "555000111222", "margonemId": "100001" }
await fetch("https://evocation.nerthus.pl/v1/api/auth/link", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ discordId: "555000111222", margonemId: "100001" }),
}).then((r) => r.json());

Response 200:

{ "margonem_id": "100001", "discord_id": "555000111222", "gracz": "Stefan", "linked_via": "margonem", "linked_at": "2026-07-01T12:00:00Z" }

A body missing both ids is 400, nothing written.

POST /v1/api/auth/link
Content-Type: application/json
Authorization: Bearer <token>

{ }
await fetch("https://evocation.nerthus.pl/v1/api/auth/link", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
}).then((r) => r.json());

Response 400:

{ "error": "DiscordIdAndMargonemIdRequired", "detail": "discordId and margonemId required" }

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.

GET /v1/api/auth/whoami
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/auth/whoami", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "kind": "machine", "margonemId": null, "gracz": null, "capabilities": ["admin.all"] }

Rejected: no token

whoami needs identity.read, so an anonymous request is 401 — authentication precedes everything.

GET /v1/api/auth/whoami
await fetch("https://evocation.nerthus.pl/v1/api/auth/whoami").then((r) => r.json());

Response 401:

{ "error": "Unauthorized" }

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.

GET /v1/api/auth/whoami
Authorization: Bearer nak_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
await fetch("https://evocation.nerthus.pl/v1/api/auth/whoami", {
  headers: { "Authorization": "Bearer nak_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" },
}).then((r) => r.json());

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.

GET /v1/api/auth/whoami
Authorization: Bearer nak_ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
await fetch("https://evocation.nerthus.pl/v1/api/auth/whoami", {
  headers: { "Authorization": "Bearer nak_ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" },
}).then((r) => r.json());

Response 401:

{ "error": "Unauthorized" }

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.

POST /v1/api/api-keys
Content-Type: application/json
Authorization: Bearer <token>

{ "margonemId": "100001" }
await fetch("https://evocation.nerthus.pl/v1/api/api-keys", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ margonemId: "100001" }),
}).then((r) => r.json());

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.

GET /v1/api/api-keys/claim/eaf2b520e5412a1d6bb8636e69e487926bcea5c94af1980d628d15035386bea1

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.

GET /v1/api/identity/100001
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/identity/100001", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200 (trimmed):

{ "Gracz": "Stefan", "MargonemId": "100001", "Capabilities": ["entity.read", "player.read"] }

Rejected: unknown identity

An id that matches no Gracz is 404.

GET /v1/api/identity/999999
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/identity/999999", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 404:

{ "error": "IdentityNotFound", "detail": "Identity not found" }

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.

GET /v1/api/capabilities
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/capabilities", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "count": 1, "capabilities": ["admin.all"] }

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.

POST /v1/api/capabilities/grant?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>

{ "margonemId": "100001", "capability": "pu.read" }
await fetch("https://evocation.nerthus.pl/v1/api/capabilities/grant?dryRun=true", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ margonemId: "100001", capability: "pu.read" }),
}).then((r) => r.json());

Response 200:

{ "wouldGrant": "pu.read", "margonemId": "100001" }

Rejected: grant without fields

Omitting margonemId or capability is 400.

POST /v1/api/capabilities/grant
Content-Type: application/json
Authorization: Bearer <token>

{ }
await fetch("https://evocation.nerthus.pl/v1/api/capabilities/grant", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
}).then((r) => r.json());

Response 400:

{ "error": "MargonemIdAndCapabilityRequired", "detail": "margonemId and capability required" }

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.

POST /v1/api/capabilities/revoke?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>

{ "margonemId": "100001", "capability": "pu.read" }
await fetch("https://evocation.nerthus.pl/v1/api/capabilities/revoke?dryRun=true", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ margonemId: "100001", capability: "pu.read" }),
}).then((r) => r.json());

Response 200:

{ "wouldRevoke": "pu.read", "margonemId": "100001" }

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.

POST /v1/api/tokens
Content-Type: application/json
Authorization: Bearer <token>

{ "name": "ci-tool", "capabilities": ["entity.read"] }
await fetch("https://evocation.nerthus.pl/v1/api/tokens", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "ci-tool", capabilities: ["entity.read"] }),
}).then((r) => r.json());

Response 201:

{ "token": "tok_9f2c…", "name": "ci-tool", "capabilities": ["entity.read"] }

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.

GET /v1/api/tokens
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/tokens", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200 (trimmed):

{ "count": 1, "items": [{ "Id": "…", "Kind": "named", "Name": "ci-tool", "Expires": null }] }

Revoke a token

DELETE /v1/api/tokens/{id} deletes the token record by its id. An id that matches no record is 404.

DELETE /v1/api/tokens/nieistniejacy
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/tokens/nieistniejacy", {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 404:

{ "error": "TokenNotFound", "detail": "token not found" }