Skip to content

Auth, identity & tokens

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. 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/discord — (Discord OIDC sign-in)
POST /auth/link Connect-NerthusIdentity identity.link
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

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.

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" }

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. A body without id_token is rejected 400 before any verification.

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": "id_token required" }

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": "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" }

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": "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": "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 (or a role's bundle). 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.

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": "token not found" }