# Grimoire API Reference **Version:** 0.1.0 ## Interactive docs The live API is self-documented via OpenAPI. With the server running: | URL | Description | |-----|-------------| | `http://localhost:9481/api/docs` | **Swagger UI** - interactive, try-it-out docs | | `http://localhost:9481/api/redoc` | **ReDoc** - clean, readable reference | | `http://localhost:9481/api/openapi.json` | Raw OpenAPI schema | Every JSON endpoint declares a response model, so the spec carries real response types (usable for client generation) rather than an empty schema. The only routes without a body schema are the ones that do not return JSON - file, image and archive downloads, OIDC redirects, and the SPA catch-all. `backend/tests/test_openapi_response_models.py` enforces this: a new JSON route that omits its `response_model=` fails the suite. --- ## Authentication The interactive docs (`/api/docs`, `/api/redoc`) and `/api/openapi.json` need a signed-in session by default; set [`API_DOCS`](configuration.md) to `public` or `off` to change that. All endpoints except `/api/health`, `/api/auth/status`, `/api/auth/setup`, `/api/auth/login`, `/api/auth/guest-login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/config` require a JWT - or, for external integrations, an [API key](#api-keys). **Header** (preferred for API clients): ``` Authorization: Bearer ``` **Session cookie** (used by browser-embedded images and file downloads): On a successful `/api/auth/login`, `/api/auth/setup`, `/api/auth/guest-login`, or OIDC callback, the server also sets an `HttpOnly`, `SameSite=Lax` cookie named `grimoire_session` carrying the JWT (marked `Secure` when `BASE_URL` is `https://`, or when the request arrived over HTTPS - directly, or as `X-Forwarded-Proto: https` from a [trusted proxy](security.md)). `` and download requests - which can't set an `Authorization` header - authenticate via this cookie, so the token never appears in the URL. `POST /api/auth/logout` clears it. The cookie authenticates **`GET` and `HEAD` requests only**. A browser attaches the cookie by itself, and `SameSite=Lax` still lets a page on a sibling subdomain send one with a `POST`, so a request that changes anything must carry the `Authorization` header (or an `X-API-Key`); with only the cookie it gets a `401`. A JWT in the URL (`?token=`) is **not accepted**: it leaked into proxy and access logs, `Referer` headers and browser history ([#156](https://github.com/hunter-read/grimoire/issues/156)). Use the header, or the cookie for browser `GET`s. The auth precedence for any request is: `X-API-Key` → `Authorization` header → `grimoire_session` cookie, the last on `GET`/`HEAD` only. Access tokens carry `"typ": "access"`, and a JWT whose `typ` is anything else - or that is missing `sub`, `username` or `role` - is a `401`. Tokens issued before the claim existed are still accepted until they expire. The web app keeps its access token in memory only, never in `localStorage`; after a reload it gets a new one from the refresh cookie. Tabs serialise their refreshes with the Web Locks API, so two tabs refreshing at once don't present the same refresh token twice. ### Token lifetimes and revocation Access tokens are **short-lived** and paired with a long-lived refresh token, so a leaked token is no longer valid for a month with no way to stop it ([#157](https://github.com/hunter-read/grimoire/issues/157)). | Credential | Default lifetime | Env var | Where it lives | |------------|------------------|---------|----------------| | Access token (JWT) | 30 minutes | `ACCESS_TOKEN_EXPIRE_MINUTES` | `Authorization` header, `grimoire_session` cookie | | Refresh token | 30 days idle | `REFRESH_TOKEN_EXPIRE_DAYS` | `grimoire_refresh` cookie (`HttpOnly`, `SameSite=Strict`, scoped to `/api/auth`) | Every login - password, first-run setup, guest code, and OIDC - creates a row in `auth_sessions`. The refresh token is bound to that row and only its SHA-256 is stored, so a database leak yields no usable sessions. **How revocation works.** Revoking a session invalidates its refresh token immediately. The access token stays valid until it expires (at most `ACCESS_TOKEN_EXPIRE_MINUTES`), which is the deliberate trade for keeping access-token checks stateless and database-free on the hot path. Lower `ACCESS_TOKEN_EXPIRE_MINUTES` to narrow that window. Refresh tokens are **single-use and rotate** on every exchange. Presenting a token that was already exchanged is treated as evidence it leaked and revokes the entire session rather than merely refusing the call. Sessions are revoked automatically when: - the user logs out (that session only); - an admin changes the user's role or resets their password (all sessions); - the user changes their own password (all sessions *except* the current one); - a guest is removed or their invite code is regenerated; - the user account is deleted. When an access token expires, the response carries an `X-Token-Expired: 1` header alongside the 401 so clients can distinguish "refresh and retry" from a genuine authentication failure. The web client refreshes and replays the request automatically, sharing one in-flight refresh across concurrent 401s (parallel exchanges would rotate each other into invalidity). ### API keys Scripts and integrations - a [Homepage](https://gethomepage.dev) widget, a command-line tool such as [grimoire-cli](https://github.com/thomaslazar/grimoire-cli), a backup script - authenticate with an **API key** instead of a session ([#489](https://github.com/hunter-read/grimoire/issues/489)): ``` X-API-Key: grim_... ``` **Keys are personal.** Every key belongs to a user and acts **as that user** - their role, their campaigns, their favourites - narrowed by the key's own permissions. A key can never do more than its owner: role guards and handlers run exactly as for the owner's session, and use the owner's *current* role, so a demoted user's keys lose what the demotion took away. Keys are deleted with their owner. Users create keys under **Settings → Account → Security → API Keys** (or through [`/api/api-keys`](#api-keys-1)). Who may: - **The instance** - API keys are on unless `API_KEYS_ENABLED=false`, which turns them off for everyone, admins included: every key is refused, every `/api/api-keys` endpoint returns `403`, and the UI hides the key menus. - **Each user** - admins always may. Anyone else only once an admin ticks **API keys** on their row in **Settings → Users** (`api_keys_enabled`, off by default), or the OIDC [permissions claim](oidc.md) grants `apiKeys`. Taking it away stops that user's existing keys (`403`) without deleting them. - **Guests** never hold keys. `GET /api/auth/me` (and the `user` block of every login response) says whether the caller may use keys right now, as `api_keys_allowed`. Each key has a name, an optional expiry, and a level per **permission**: | Level | Allows | |-------|--------| | **No access** (default) | Nothing | | **Read** | `GET` requests, plus `POST` routes marked read-only | | **Read and write** | Every method | A permission is an area of the API, taken from each endpoint's OpenAPI tag: | Permission | Tags | Covers | |------------|------|--------| | `stats` | `stats` | `/api/stats` - library counts and totals | | `library` | `library` | Scan status, rescan and cancel, cleanup of missing items (`/api/maintenance/cleanup-missing`), version and changelog | | `books` | `books` | Books, metadata, covers, pages and files | | `systems` | `systems` | Game systems, covers and book folders | | `search` | `search` | Full-text search | | `tags` | `tags` | Tag management and tagged items | | `lookups` | `lookups` | Genres, system families, parent systems, licenses, dice/materials | | `maps` | `maps` | Maps and map folders | | `tokens` | `tokens`, `token-frames` | Tokens, token folders and token frames | | `audio` | `audio` | Audio tracks, covers and folders | | `models` | `models` | 3D models and model folders | | `campaigns` | `campaigns` | The owner's campaigns: members, sessions, wiki, resources, schedule | | `downloads` | `downloads` | Zip archive downloads | | `files` | `files` | The library file manager | | `duplicates` | `duplicates` | Duplicate detection and resolution | | `addons` | `addons` | Community metadata add-ons | | `codex` | `codex` | Grimoire Codex DB connection settings, sending and unlinking records | | `maintenance` | `maintenance` | Metadata sidecar settings and export | | `backups` | `backups` | Backups and the backup schedule | | `logs` | `logs` | Application logs | | `settings` | `settings` | App settings, including `/api/settings/ui` | | `users` | `users` | User management, and the owner's own preferences | | `personal` | `favorites`, `bookmarks`, `saved-filters`, `audio-sets`, `themes` | The owner's favourites, bookmarks, saved filters, saved audio sets and themes | **All permissions.** A key's permissions may also hold `"*"`, which raises every permission - including any added in a later version - to at least that level. An explicit entry can still go higher, so `{"*": "read", "books": "write"}` reads everything and edits books. **Levels follow the role.** `GET /api/api-keys/permissions` lists only what the caller's role can use, derived from each route's role guard (`require_admin`, `require_gm_or_admin`, `require_not_guest`): a player is offered game systems at Read only and never sees `logs` or `files`; `stats`, `search`, `downloads` and `logs` never offer Read and write because they have no write endpoints. Creating or editing a key with a level the role doesn't offer is a `400`. This is about what is *offered* - a handler that checks the role itself still refuses as it would for the owner. **Read-only `POST`s.** A few endpoints use `POST` only to carry a request body and change nothing; they count as Read. They are marked in the OpenAPI schema with `x-api-key-access: read`: `POST /api/books/{id}/metadata-search`, `/api/books/{id}/metadata-fetch`, `/api/systems/{id}/metadata-search`, and `/api/systems/{id}/metadata-fetch`. **Never available to a key**, whatever its permissions (these return `403`): the `auth` tag (sign-in, sessions, OIDC) and `api-keys` - a key can never create or widen keys - plus routes marked `x-api-key-access: none` because they change a credential or the account itself: `/api/users/me/password`, `DELETE /api/users/me`, `/api/users/me/opds*`, `/api/campaigns/calendar/subscription`, the guest invite code routes (`POST`/`GET /api/campaigns/{id}/guests`, `/api/campaigns/{id}/guests/{member_id}/regenerate` and `/share-template`), the OIDC login-button icon (`/api/settings/oidc-button-icon`), the add-on source settings (`PATCH /api/addons/settings`) and private source tokens (`/api/addons/sources/credential`). OPDS and the calendar feeds keep their own per-user tokens. **Session-only fields.** Some routes a key can use take a few fields that only a signed-in session may set; sent with a key they are a `403` naming that, and the rest of the route works as usual: - `POST /api/users` and `PATCH /api/users/{id}`: `password`, the `admin` role, and (on `PATCH`) a changed `email`. `POST /api/users/{id}/convert` likewise. - `PATCH /api/users/me/preferences`: a changed `email` or `allow_explicit`. **Routes a key cannot use at all** beyond the excluded tags: `GET /api/share-links/all` and `GET /api/share-links/users/{user_id}`. They list every user's live share URLs, which are credentials in their own right, so they answer a key with `403` whatever its permissions; an admin reads them from a signed-in session. - `PATCH /api/settings`: `password_auth_enabled`, `guest_access_enabled`, the login message, and every `oidc_*` field. - `POST /api/addons/{id}/install` with `approve_script: true`, and `PATCH /api/addons/{id}` with `script_approved: true`. Campaign responses never carry a guest's `guest_code` to a key. **Share links with a key.** Creating a share link or changing one in any way also needs the key to hold Read on the item's own area - `books`, `maps`, `tokens`, `audio` or `models` - besides `share-links: Read and write`. **Responses.** An unknown or expired key gets `401`. A valid key on an excluded route, without the level the request needs, or whose owner may no longer hold keys gets `403`, as does any key while `API_KEYS_ENABLED=false`; a missing level names the permission and levels, e.g. `This API key needs 'write' access to 'books' (it has 'read')`. When a request carries both a session and `X-API-Key`, the key is used, so the request is held to the key's permissions. **Storage.** Keys are `grim_` followed by 32 random bytes. Only a SHA-256 hash is stored, and the full key is returned exactly once - by create or regenerate. After that only its prefix (the first 12 characters) is shown. A lost key cannot be recovered, only regenerated. `last_used_at` is updated at most once a minute per key. **Migration from the stats key.** The single stats API key that earlier versions kept in plaintext is migrated on upgrade: it keeps working unchanged, named `Stats API key (migrated)`, owned by the earliest-created admin, with `stats: Read` and nothing else. **MCP.** With `MCP_ENABLED=true`, keys also authenticate the [MCP server](mcp.md) at `POST /mcp` (outside `/api`). There the route is always the same, so each MCP tool, resource and prompt checks the permission it needs - `search_library` needs `search: Read`, `create_wiki_page` needs `campaigns: Write` - and a key is only shown the ones it can use. See [What it can do](mcp.md#what-it-can-do) for the full table. ### Share links A [share link](sharing.md) is a public URL for one book, map, token, audio track or 3D model, so a player's browser, a VTT or a Discord bot can load it with no account. The token in the path is the only credential: `/api/share/*` takes no session, cookie (other than a password unlock) or API key, and grants nothing but that one item. See [Share links](#share-links-1) below. ### Rate limiting The credential-checking endpoints - `/api/auth/login`, `/api/auth/setup`, and `/api/auth/guest-login` - are rate-limited per client IP (default `10/minute`, configurable via `AUTH_RATE_LIMIT`). Exceeding the limit returns `429 Too Many Requests` with `{"error": "Rate limit exceeded: ..."}`. Keying honors `X-Forwarded-For` from a trusted reverse proxy. Failed logins are also counted per account (`ACCOUNT_RATE_LIMIT`, default `20/hour`); past it, `/api/auth/login` returns `429` for that account until the window passes, except from a browser that has signed in to it before. See [Security hardening](security.md). Failed `X-API-Key` attempts (unknown or expired keys) count against the same per-IP limit, on every endpoint. Once an IP is over it, further failed attempts get `429` until the window passes. A working key is never refused and never counts, so a dashboard can poll as often as it likes, even from an address it shares with others. ### Calling the API from a browser (CORS) Server-side tools (Homepage, grimoire-cli, scripts) need nothing extra. Code that runs in a web page on another origin - a Foundry VTT module, a browser extension, a dashboard - is blocked by the browser unless Grimoire sends CORS headers, which it does only for the origins listed in `CORS_ALLOWED_ORIGINS`: ```yaml environment: CORS_ALLOWED_ORIGINS: "https://foundry.example.com,http://localhost:30000" ``` - **Off by default.** Unset, no CORS headers are sent and preflights are not answered. - **Exact origins only** - `scheme://host[:port]`, comma-separated. A wildcard, a path, or a non-`http(s)` entry is ignored with a warning in the log. - **Allowed request headers:** `Authorization`, `Content-Type`, `X-API-Key`. Methods: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`. - **Exposed response header:** `X-Token-Expired`, so a bearer-token client can tell an expired token from a bad one. - **No credentials.** `Access-Control-Allow-Credentials` is never sent, so the browser does not send Grimoire's cookies cross-origin and another site cannot use a signed-in user's session. Authenticate with an [API key](#api-keys) or a bearer token, which the key's permissions already scope. - Error responses (`401`, `403`, `429`) carry the headers too, so the client can read what went wrong. ### Roles | Role | Permissions | |------|-------------| | `admin` | Full access including user management and app settings | | `gm` | Read + edit metadata, rescan library, manage campaigns | | `player` | Read-only access | --- ## Endpoints ### Health | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/health` | GET | - | Unauthenticated readiness probe used by the container `HEALTHCHECK`. Checks the database (always) and Valkey (only when configured). Returns `200` with `{"status": "ok", "checks": {...}}` when all dependencies are reachable, or `503` with `{"status": "unhealthy", ...}` otherwise. | ### Auth | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/auth/status` | GET | - | Returns `{"initialized": bool}` - used by the frontend to decide whether to show first-run setup | | `/api/auth/config` | GET | - | Public auth configuration for the login screen: `{password_auth_enabled, guest_access_enabled, custom_login_message_enabled, custom_login_message, oidc_enabled, oidc_button_text, oidc_button_bg_color, oidc_button_text_color, oidc_button_border_color, oidc_button_radius, oidc_button_icon_url, oidc_auto_launch}`. The custom message is only returned when its toggle is on. OIDC fields are only true/non-empty when the IdP is fully configured. The `oidc_button_*` appearance fields are validated hex colors or `""` (theme default), `oidc_button_radius` is px or `null`, and `oidc_button_icon_url` is a relative URL or `""` - see [Style the sign-in button](oidc.md#style-the-sign-in-button). | | `/api/auth/setup` | POST | - | First-run admin account creation. Body: `{username, password}`. Returns `{token, user}` and sets the `grimoire_session` cookie. Fails with 400 if any users exist. Two setups racing each other can't both create an admin: the second is refused. | | `/api/auth/login` | POST | - | Authenticate. Body: `{username, password}`. Returns `{token, user}` (`user` includes `display_name`) and sets the `grimoire_session` cookie. Returns 403 if password authentication is disabled. | | `/api/auth/guest-login` | POST | - | Exchange a campaign guest invite code for a JWT. Body: `{code}`. Returns `{token, user, campaign_id}` and sets the `grimoire_session` cookie - `user.display_name` is the GM-set guest nickname. Returns 403 if guest access is disabled, 401 for an unknown/expired code. | | `/api/auth/logout` | POST | - | Revokes the current session (identified by the `grimoire_refresh` cookie) and clears both auth cookies. Requires no auth so a client with an expired access token can still log out. The refresh token dies immediately; the current access token remains valid until it expires. | | `/api/auth/refresh` | POST | - | Exchanges the `grimoire_refresh` cookie for a new access token, rotating the refresh token. Returns `{token, user}` and re-sets both cookies. Returns 401 when the refresh token is missing, expired, or revoked; reusing an already-rotated token revokes the whole session. Rate-limited like the other credential endpoints. | | `/api/auth/me` | GET | any | Current user: `{id, username, display_name, email, role, allow_explicit, explicit_locked, campaign_access, api_keys_allowed, oidc_linked}`. `explicit_locked` is `true` when explicit content was turned off *for* the user (by an admin, the seed file or the OIDC `viewNSFW` permission), so their own preference can't turn it back on. `api_keys_allowed` says whether they may use [API keys](#api-keys) right now. Also (re-)sets the `grimoire_session` cookie when the request authenticated via header but had no cookie, so clients that predate the cookie get one on next load. | | `/api/auth/sessions` | GET | any | The caller's own live sessions, newest first: `[{id, origin, user_agent, ip_address, created_at, last_used_at, expires_at, current}]`. `origin` is `password`, `guest`, or `oidc`; `current` marks the session backing this request. | | `/api/auth/sessions/others` | DELETE | any | Logs out everywhere else - revokes all of the caller's sessions except the current one. Returns `{ok, revoked, kept_current}`. | | `/api/auth/sessions/{session_id}` | DELETE | any | Revokes a single session belonging to the caller. Returns 404 for an unknown session or one owned by another user. | | `/api/auth/openid/login` | GET | - | Start an OIDC login. Redirects to the IdP. Optional `?return_to=/path` to redirect after callback - it must be a same-origin path, and anything else (another host, `//host`, backslashes, control characters) falls back to `/`. Returns 503 if OIDC isn't configured. Rate limited to 30 a minute per address, since each call stores sign-in state before anything is checked. Sets a short-lived (10 minute) HttpOnly, SameSite=Lax `grimoire_oidc_flow` cookie, scoped to `/api/auth/openid`, that ties the sign-in to this browser. | | `/api/auth/openid/callback` | GET | - | OIDC callback. Checks and spends the `state` first - before anything else, an IdP error included - and refuses it unless the `grimoire_oidc_flow` cookie set by `/login` for that state comes with it, so a callback link only completes in the browser that started the sign-in (the cookie is cleared either way). Then validates the code, finds/creates the local user, opens a revocable session (`origin: "oidc"`, with the client's address and user agent recorded as for a password sign-in), sets the `grimoire_session` and `grimoire_refresh` cookies, and redirects to the `return_to` path. **No token travels in the URL**: the SPA picks up the session from the refresh cookie, as it does for a returning user. Failures redirect to `/login?oidc_error=` (URL-encoded); an IdP error is shown as a fixed message for its OAuth error code, never the IdP's `error_description`. Userinfo claims are used only when their `sub` matches the ID token's, and `email`/`email_verified` always come together from one of the two. | | `/api/auth/openid/button-icon` | GET | - | The admin-configured icon for the OIDC button, always a server-normalized PNG (≤192 px). 404 when no icon is set. Served with `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; sandbox`. Use the URL from `/api/auth/config`: when its `v` query parameter matches the current icon the response is cacheable forever; otherwise it is `no-cache` with an `ETag`. | | `/api/auth/openid/discover` | POST | admin | Server-side discovery fetch. Body: `{issuer_url}`. Returns the relevant endpoints from `.well-known/openid-configuration`. | ### Users | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/users` | GET | admin | List all users (each entry includes `email`, `allow_explicit`, `campaign_access`, `api_keys_enabled`, `share_links`/`share_links_allowed`/`share_links_source` (see below), `campaign_count` (number of campaigns the user owns), `oidc_linked`, and `last_login_at`/`last_active_at` - when the account last signed in and was last seen, UTC, `null` for never. Activity is recorded on sign-in, on each token refresh and on API-key use, at most every 5 minutes, so it is only as precise as `ACCESS_TOKEN_EXPIRE_MINUTES`) | | `/api/users` | POST | admin | Create a user. Body: `{username, password?, role?, email?, allow_explicit?, campaign_access?, api_keys_enabled?, share_links?}` (role defaults to `player`; email is optional and unique case-insensitively; `password` may be omitted to create an OIDC-only account when password auth is disabled, otherwise it must be ≥8 chars). Returns the created user with `allow_explicit`, `campaign_access`, `campaign_count`, and `oidc_linked`. Usernames are unique regardless of case (`Admin` and `admin` are the same name, and so are `Émile` and `émile` - names are compared with full Unicode case folding), as sign-in compares them. A name that is taken returns `400`. | | `/api/users/guests` | GET | admin | List every per-campaign guest account. Each entry: `{id, display_name, created_at, campaign_id, campaign_name, invited_by}` (`invited_by` is the campaign owner's display name/username). Guests never appear in `GET /api/users`. | | `/api/users/:id` | PATCH | admin | Update `role`, `password`, `allow_explicit`, `campaign_access`, `api_keys_enabled`, `share_links`, or `email` (use `""` to clear the email). `share_links` is the user's [share link](#share-links-1) override: `"role"` follows the role default, `"allow"`/`"deny"` grant or refuse it whatever the role. Responses report it alongside `share_links_allowed` (the effective answer) and `share_links_source` (`role`, `user` or `disabled`), so a GM with no override reads as allowed by role. `api_keys_enabled` lets a non-admin hold [API keys](#api-keys) (admins always may); OIDC's `apiKeys` permissions claim overrides it on next login. `campaign_access: false` blocks the user from creating/joining/managing campaigns without deleting existing ones; OIDC's `campaignAccess` permissions claim overrides it on next login. Changing a GM's role **drops their access grants** - see [Access levels](#access-levels-issue-258). A guest's role can't be changed here (400): a guest signs in with its invite code, which only works for the guest role, so promote one with `POST /api/users/:id/convert`. | | `/api/users/:id/convert` | POST | admin | Convert a guest account to a permanent user. Body: `{username, password?, role?}` (role defaults to `player`, cannot be `guest`). `password` is required only when password auth is enabled; ≥8 chars. Keeps the guest's campaign membership and character, clears its invite code, and returns the promoted user. 400 if the target isn't a guest or the username is taken. | | `/api/users/:id/merge` | POST | admin | Fold one or more guest accounts into the account at `:id`, so someone invited to several campaigns ends up with a single login. Body: `{source_ids: [...]}` (non-empty, no duplicates, cannot contain `:id`). Moves each source's campaign memberships, notes, character-builder characters, installed sheets, personal rulesets, and other personal rows onto the target, then deletes the emptied sources and ends their sessions. A source's access grants, API keys and share links are deleted rather than moved, since they were issued to that account alone. Merged-in memberships have their invite code cleared - campaign access comes from the membership itself, so the person keeps every campaign and uses the surviving account's credentials. Rows that would collide (target is already in that campaign, has that sheet installed, has favourited the same item, has a saved filter with that name, has answered availability for that date, or has a note on that session) are dropped in favour of the target's; if both have a default saved filter for the same view, the target's stays the default. Sources must be guests; the target may be a guest or a permanent user. Returns `{id, display_name, merged_ids, memberships_moved}`. 400 if a source isn't a guest or `:id` is among the sources, 404 if the target or any source is missing. | | `/api/users/:id` | DELETE | admin | Delete a user (cannot delete self or last admin). Also the way to remove a guest account, including one orphaned by its campaign's deletion (null `campaign_id`/`invited_by`). | | `/api/users/:id/access-grants` | GET | admin | List this user's library access grants. Each: `{id, user_id, scope_type, scope_id, scope_name, level}`. `scope_name` is `""` when the granted system/book has since been deleted. | | `/api/users/:id/access-grants` | POST | admin | Grant access to one restricted system or book. Body: `{scope_type: "system"\|"book", scope_id, level: "gm"\|"admin"}`. Only **GMs** may hold grants (400 otherwise) - admins already see everything and players cannot be granted past a restriction. Re-granting an existing scope updates its level rather than erroring. 404 if the target system/book does not exist. See [Access levels](#access-levels-issue-258). | | `/api/users/:id/access-grants/:grant_id` | DELETE | admin | Revoke a grant. `204` on success. | | `/api/users/me/preferences` | PATCH | any | Update own `display_name`, `allow_explicit`, or `email` (use `""` to clear). Turning `allow_explicit` on is `403` while `explicit_locked` is set (see `/api/auth/me`); turning it off is always allowed. An admin setting `allow_explicit: false` on a user locks it, and `true` unlocks it. | | `/api/users/me/password` | PATCH | any | Change own password. Body: `{current_password, new_password}` | | `/api/users/me` | DELETE | any | Delete own account (admin accounts cannot self-delete) | | `/api/users/me/opds` | GET | any | Own [OPDS](opds.md) feed status: `{opds_enabled, has_token, feed_url, feed_urls}`. `feed_url` is always the v1 URL, as it always has been. `feed_urls` lists every feed version, newest first: `[{version: "v2", url}, {version: "v1", url}]`. Both URLs are `null` without a token. Guests, and every user while `OPDS_ENABLED` is off, get `{opds_enabled: false}`. | | `/api/users/me/opds/generate` | POST | any | Generate (or regenerate) own OPDS token and return the same shape as the status. The previous token stops working immediately, for every feed version. `403` for guests or when OPDS is disabled. | | `/api/users/me/opds` | DELETE | any | Revoke own OPDS token. Every feed version's URL stops working. | ### OPDS feeds Served outside `/api` when `OPDS_ENABLED` is on, and authenticated by the token in the path rather than a header. An unknown or revoked token returns `404`. See [OPDS catalog](opds.md) for what each version contains. | Endpoint | Version | Description | |----------|---------|-------------| | `/opds/v2/:token` | v2 | Navigation root: All Books, one entry per game system (with its book count), then Other Books when some books have no system | | `/opds/v2/:token/all` | v2 | Every book the user may see | | `/opds/v2/:token/system/:system_id` | v2 | One game system's books. `404` for a system the user may not see, including an explicit one when their explicit setting is off | | `/opds/v2/:token/other` | v2 | Books with no game system | | `/opds/v2/:token/entry/:book_id` | v2 | Single-book entry | | `/opds/v2/:token/download/:book_id`, `/opds/v2/:token/cover/:book_id` | v2 | File download and WebP cover | | `/opds/:token` | v1 | Navigation root with the one All Books entry | | `/opds/:token/all` | v1 | Every book the user may see | | `/opds/:token/entry/:book_id` | v1 | Single-book entry | | `/opds/:token/download/:book_id`, `/opds/:token/cover/:book_id` | v1 | File download and WebP cover | Each version's links stay inside that version, so a reader subscribed to v1 sees exactly what it always has. ### Library | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/stats` | GET | any (not guest), or an API key with `stats` | Counts, page totals, library size. Tagged `stats` rather than `library`, so a dashboard key needs nothing else. | | `/api/about` | GET | any | Build info for the About dialog: `{version, commit_hash, python_version}`. Deliberately **not** exposed on `/api/stats`, so a `stats`-only key can't read these details. | | `/api/changelog` | GET | any (JWT) | Parsed `CHANGELOG.md` for the About dialog: `{releases: [{version, date, summary, sections: [{title, entries}]}]}`, newest release first. `date` and `summary` are `null` when the release heading carries neither (an `Unreleased` section has no date). `releases` is empty when the image ships without a changelog, which the dialog renders as no changelog section rather than an error. Parsed once and cached for the process lifetime - the file cannot change under a running container. | | `/api/latest-release` | GET | any (JWT) | Latest published release for the update-available check: `{latest_version}` (or `null`). Proxies GitHub's releases API server-side (cached ~1h) so the browser makes a same-origin request that request blockers won't block. Returns `null` when `DISABLE_VERSION_CHECKING` is set or GitHub is unreachable. | | `/api/scan-status` | GET | admin | Current scan state. `phase` is `scanning` (file walk), `indexing` (text-layer extraction), or `ocr` (deferred OCR of scanned/image-only PDFs). During the `ocr` phase, `total_ocr`/`ocr_done`/`ocr_current` report the OCR queue's progress. `kind` is `reindex` while the running job is the per-book re-index queue - books replaced by upload and single-book rescans - which accepts further replace uploads and rescans; it is `null` for every other job. `heartbeat` is the last time the running scan showed signs of life (ISO 8601, UTC) - it advances at least every 30 seconds while the scan is alive and stops when its process dies | | `/api/rescan` | POST | admin | Trigger a background rescan and reindex (optionally scoped, with a metadata-refresh mode). A scan left stuck by a dead process does not block it | | `/api/cancel-scan` | POST | admin | Request a graceful stop of the running scan or indexing job (`stop_requested`), or clear one whose process died (`cleared_stale`). `not_running` when there was nothing to stop. Without Valkey, a startup scan running on another worker counts as running | **Stats response:** ```json { "game_systems": 12, "books": 340, "maps": 1500, "tokens": 800, "audio": 250, "indexed_books": 320, "total_pages": 45000, "total_size_mb": 18240.5, "library_size_mb": 41890.2 } ``` To surface library counts on an external dashboard, create an [API key](#api-keys) under **Settings → Account → Security → API Keys** with **Statistics: Read** and nothing else. The counts are the key owner's, so an admin's key sees the whole library. The key is shown once, when you create it; regenerate or revoke it there at any time. **Homepage Custom API widget** - add this to your Homepage `services.yaml` ([Custom API widget docs](https://gethomepage.dev/widgets/services/customapi/)): ```yaml - Grimoire: href: https://grimoire.example.com icon: grimoire.png widget: type: customapi url: https://grimoire.example.com/api/stats refreshInterval: 60000 headers: X-API-Key: your-key-here mappings: - field: books label: Books format: number - field: maps label: Maps format: number - field: tokens label: Tokens format: number - field: library_size_mb label: Size format: float scale: 0.001 suffix: " GB" ``` Homepage shows up to four fields per row; pick the counts you care about from the available fields below. A `refreshInterval` of 60s is plenty - the counts only change when the library does. | Field | Meaning | Suggested `format` | |-------|---------|--------------------| | `game_systems` | Number of game systems. Excludes special collections and container folders, but *includes* the systems nested inside a container | `number` | | `books` | Total books (PDFs) | `number` | | `maps` | Total maps | `number` | | `tokens` | Total tokens | `number` | | `audio` | Total audio tracks | `number` | | `indexed_books` | Books with a searchable full-text index | `number` | | `total_pages` | Sum of all book page counts | `number` | | `total_size_mb` | Size of the books in MB (PDFs and other book formats only) | `float` - add `scale: 0.001` + `suffix: " GB"` to show GB | | `library_size_mb` | Size of the whole library in MB - books plus maps, tokens, audio, and 3D models | `float` - add `scale: 0.001` + `suffix: " GB"` to show GB | **Scan-status response:** ```json { "running": true, "phase": "scanning", "scanned_books": 120, "total_books": 340, "scanned_maps": 0, "total_maps": 1500, "scanned_tokens": 0, "total_tokens": 800, "scanned_audio": 0, "total_audio": 250, "new_books": 5, "new_maps": 0, "new_tokens": 0, "new_audio": 0, "updated_books": 0, "indexed": 80, "to_index": 320 } ``` `phase` is `"scanning"`, `"indexing"`, or `null` when idle. `updated_books` counts books whose metadata was re-applied from sidecar files during a metadata-refresh rescan. `replaced_books` counts books whose contents changed under an unchanged path (re-indexed in place), and `moved_files` counts files recognised as moved rather than deleted-and-re-added. **Rescan request body** (all fields optional): ```json { "scope": "books/D&D 5e/adventure", "metadata_mode": "missing" } ``` | Field | Default | Description | |-------|---------|-------------| | `scope` | `null` | Restrict the rescan to a subtree relative to the library root. Must begin with `books/`, `maps/`, `tokens/`, `audio/`, or `models/`. Omit to rescan the whole library. Paths that escape the library root return `400`. | | `metadata_mode` | `"new"` | `"new"` adds new files and flags missing ones (existing records untouched). `"missing"` additionally fills **empty** book fields from sidecar metadata (`.opf` / `metadata.opf`), leaving fields you've already set in place. `"replace"` overwrites fields wherever the sidecar provides a value (destructive to UI-edited metadata). | Returns `{"status": "scan_started"}`, or `{"status": "already_running"}` if a scan is already in progress (scoped and global rescans share the same single-worker lock). **Cancel-scan response:** ```json {"status": "stop_requested"} ``` Returns `{"status": "not_running"}` if no scan is in progress. Cancellation is cooperative - the running scan checks for the stop signal after each file and exits at the next safe checkpoint. Poll `/api/scan-status` until `running` is `false` to confirm it has stopped. Returns `{"status": "cleared_stale"}` when the status claimed a scan was running but its `heartbeat` had not moved for five minutes: the process behind it died before it could clear its own status (issue #524), so there is no thread left to notice a stop request. The status is reset on the spot and `running` is `false` immediately. Rescans, single-book rescans, the OCR trigger, cleanup, sidecar export, and duplicate detection likewise treat such a status as not running rather than refusing. ### Bulk operations Every bulk-editable collection (`books`, `systems`, `maps`, `tokens`, `audio`, `models`) exposes the same pair of endpoints, and the three media folder collections (`map-folders`, `token-folders`, `audio-folders`) expose a bulk folder-tag route. All require the gm/admin role. | Route | Body | Purpose | |-------|------|---------| | `POST /api//bulk` | `{items: [{id, ...PATCH fields}]}` | Per-item field edits. A per-item `tags` list **replaces** that item's tags | | `POST /api//bulk/tags` | `{ids: [...], tags: [...]}` | **Additively** applies tags - existing tags are kept | | `POST /api/{map,token,audio}-folders/bulk` | `{folders: [{path, tags}]}` | Sets tags on many folder paths | Each request is applied in a **single transaction**, which is what makes them safe: the previous client-side approach of one PATCH per selected item raced on the unique `tags.internal` constraint when several requests created the same new tag concurrently, returning intermittent `500`s (issue #270). All three return the same shape: ```json {"updated": ["id1", "id2"], "errors": [{"id": "id3", "detail": "Token not found"}]} ``` Items that cannot be applied - an unknown id, or a system rename that clashes with an existing name - are reported in `errors` and **skipped**, so one bad entry never discards the rest of the batch. `bulk/tags` additionally returns `tags` keyed by id with each resource's resulting tag list, letting clients patch local state without refetching. A batch is capped at 1000 items; `ids`/`items` must be non-empty (`422` otherwise). ### Game Systems | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/systems` | GET | any | List all systems with book counts, `total_page_count`, and metadata. Query: `sort` (`name`\|`book_count`\|`page_count`\|`year`), `order` (`asc`\|`desc`), `genre`, `family`, `parent_system`, `edition`, `license`, `explicit` (bool), `parent_id` (list one container's children), `include_children` (bool; flat list including nested systems) | | `/api/systems/:id` | GET | any | System detail + full book list, plus `children` (the nested systems when this is a container) and `scope_path` (the system's own folder, `books/{System}`, for a scoped rescan; `null` with no books). Query: `book_sort` (`category`\|`title`\|`page_count`\|`year`), `book_order`, `explicit` (bool), `genre`, `category` filter the returned books. `include_books=false` returns the summary alone (`books: []`, counts and cover computed in SQL) - what the detail view asks for, paging the shelf through the three routes below | | `/api/systems/:id/books` | GET | any (not guest) | One page of the system's books matching the [shelf filters](#browsing-a-systems-shelf). Query: the filters, plus `category` and `folder` (one folder of the grouped shelf; `""` is the category's own directory, and `folder` needs `category`), `sort` (`title`\|`year`\|`page_count`\|`size`\|`product_code`\|`added_at`), `order`, `limit` (max 500), `offset`. Returns `{total, books}` | | `/api/systems/:id/book-groups` | GET | any (not guest) | Every category and subfolder holding matching books: `{total, groups: [{category, path, dir, count, first}]}`. `path` is the subfolder below the category directory (`""` for books directly in it), `dir` the library directory (for rescan scopes), `first` the active sort's value for the folder's first book (`null` for the title sort) | | `/api/systems/:id/book-facets` | GET | any (not guest) | The filter menus' options over every book the caller may see: `{categories, genres, product_code_prefixes, tags}` | | `/api/systems/:id` | PATCH | gm/admin | Update metadata (see fields below) | | `/api/systems/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, ...PATCH fields}]}`. A name clash fails only that item | | `/api/systems/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/systems/:id/cover` | GET | any (not guest) | Serves the system's folder cover art or uploaded cover image. 404 when it has neither | | `/api/systems/:id/cover` | POST | gm/admin | Upload a cover image (multipart `file`). PNG/JPEG/WebP/GIF, max 10 MB | | `/api/systems/:id/cover/from-source` | POST | gm/admin | Set the cover from an image Grimoire already holds. Body: `{source_type, source_id}` - see [Setting an image from an existing asset](#setting-an-image-from-an-existing-asset) | | `/api/systems/:id/cover` | DELETE | gm/admin | Remove the uploaded cover. Folder art is library-managed and unaffected | | `/api/systems/:id/book-folders` | GET | any (not guest) | Book subcategory folders for this system and their tags. Returns `{folders: [{path, tags}]}` | | `/api/systems/:id/book-folders` | PATCH | gm/admin | Create or replace a folder's tag list. Body `{path, tags}`. `path` must be `{system_id}/{category}/{subfolder…}` for this system - 400 otherwise | | `/api/systems/:id/book-folders` | DELETE | gm/admin | Delete a folder row and its tags. Query: `path` (same grammar as PATCH). 404 when no such row | **PATCH fields:** `name`, `slug`, `description`, `publishers`, `character_builder_url` (legacy), `character_builder_urls`, `urls`, `cover_image`, `cover_book_id`, `tags`, `genre` (legacy), `genres`, `dice_materials`, `system_family`, `parent_system`, `edition`, `license`, `year`, `is_explicit`, `codex_id` (the linked [Grimoire Codex DB](#grimoire-codex-db-issue-35) record; unlink with `DELETE /api/codex/systems/:id/link`), `access_level` (**admin only**; `""`/`"gm"`/`"admin"` - a system has no `inherit` state. See [Access levels](#access-levels-issue-258)) **Publishers format:** `[{"name": "Publisher Name", "url": "https://..."}]` **Link-list format** (`urls`, `character_builder_urls`): `[{"label": "DriveThruRPG", "url": "https://..."}]` Every link a client sets - these lists, `publishers[].url`, `publisher_url`, `character_builder_url` and a campaign member's `character_sheet_url` - must be an `http://` or `https://` URL (or empty); anything else is a `422`. Responses apply the same rule to what is stored, returning a link with any other scheme blank, so a row written before the check (or by an add-on) is never served as one. **Multi-value metadata** (issue #202): `genres` and `dice_materials` are string arrays; `genres` supersedes the legacy single `genre`, and `urls`/`character_builder_urls` supersede the legacy single-URL fields (the legacy fields remain accepted for backward compatibility). Systems in the special one-page collection carry `is_one_page: true` (grouped with `is_system_agnostic` in the library UI). **Cover art:** a system's cover resolves in precedence order - a `cover.*`/`folder.*` image at its library folder root, then an uploaded image, then a book thumbnail. The first two are served by `/api/systems/:id/cover`; the third is a plain `/api/books/:cover_book_id/thumbnail` URL. The `has_cover` field says whether the cover endpoint will return an image, so clients can pick a source without a speculative 404. Container folders hold no books, so the endpoint is their only source of art. **System containers:** a books folder can hold *systems* rather than categories (issues #261/#262/#301) - a parent system with its editions (`books/Dungeons & Dragons/5e/…`), a one-page/micro-RPG collection where each subfolder and loose file is its own small game, a system family grouping related-but-distinct systems (`books/d20 System/Pathfinder/…`), a publisher grouping one company's systems, or a bare `.container` shelf that groups without claiming any relationship. Family and publisher containers populate their children's `system_family`/`publishers` from the container name, and containers may nest (a family holding a parent-system container). Only a parent-system container sets its children's `parent_system` - the other kinds hold independent systems, not variants. Every system summary carries `container_kind` (`""`, `"parent"`, `"one-page"`, `"family"`, `"publisher"`, `"generic"`), `parent_id`, `child_count`, `name_is_custom`, and `category_depth`. `category_depth` is the index of the category dir within the system's book paths - 2 for a top-level system, one deeper per enclosing container - so clients can split a book's `relative_path` into subfolder segments without walking the container chain themselves (issue #357). Container children are omitted from `GET /api/systems` unless `parent_id` or `include_children` is passed, and `GET /api/systems/:id` returns them in `children`. Renaming a system via `PATCH` sets `name_is_custom`, which stops the scanner reverting the name on the next rescan. **Parent system / edition:** `parent_system` (e.g. `"Dungeons & Dragons"`) is the mid-tier grouping between the broad `system_family` (`"d20 System"`) and a concrete system; `edition` (`"5e"`, `"Red"`, `"2020"`) combines with it for display (`"Cyberpunk Red"`). Both are free-text; `parent_system` values are curated via the `/api/parent-systems` lookup. Both are filterable on `/api/systems`. #### Metadata lookup from add-ons (issue #203) Sources are [Grimoire Codex DB](codex.md), built in and listed first while it is configured and enabled (`id: "grimoire-codex"`; needs `GRIMOIRE_CODEX_DB_API_TOKEN`), followed by installed add-ons. See [Grimoire Codex DB](#grimoire-codex-db-issue-35) for how Grimoire Codex DB results differ. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/systems/:id/metadata-sources` | GET | gm/admin | Grimoire Codex DB (when enabled) and installed, enabled add-ons that can supply game system metadata. Returns `{sources: [{id, name, description, homepage, attribution, supports_paste}]}` | | `/api/systems/:id/metadata-search` | POST | gm/admin | Ranked candidates from one source. Body `{source_id, query?}` - a blank `query` defaults to the system's own name. Returns `{query, results: [{identity, label, score, url}]}` | | `/api/systems/:id/metadata-fetch` | POST | gm/admin | One candidate's fields, diffed against the system. Body `{source_id, identity?, query?, paste?}`. Returns `{source_id, identity, url, attribution, fields}` | All three are **read-only** - they never write to `game_systems`. Applying goes through `PATCH /api/systems/:id` with only the fields the user selected, so a fetch can never overwrite a value on its own. **Diff rows** (`fields`) carry `{field, current, incoming, status}`, where `status` is: | Status | Meaning | |--------|---------| | `only_incoming` | The system has no value yet - safe to fill in (pre-selected in the UI) | | `differs` | Both have a value and they disagree - needs a human decision | | `same` | Already matches; nothing to apply | Rows are ordered `only_incoming` → `differs` → `same`. Fields the source has no data for are omitted entirely rather than offered as empty, so a sparse source never proposes blanking something the user filled in. **Link lists merge, they do not replace.** For `urls` and `character_builder_urls`, `incoming` is the **union** of the resource's current links and the source's, de-duplicated by URL (case-insensitively), with the user's own entries first and their labels winning. Applying therefore adds the source's link without discarding anything the user collected, and the row is `only_incoming` (so it is pre-selected) whenever the union differs from what is already stored - `same` when the source adds nothing new. Every other field still replaces. **Skipping the search (`paste`):** instead of an `identity` from a previous search, the client may send `paste` - a source URL or bare ID the user supplied directly. Grimoire resolves it via the add-on's `identity_pattern` and fetches that item, returning the resolved value in `identity` so the client can show what it actually looked up. Only offered by sources whose `supports_paste` is true. Exactly one of `identity` or `paste` is required. **Errors:** an unreachable or malformed source returns **502** with a message safe to display. **400** covers a disabled/unapproved add-on, an unknown `identity`, pasted text that does not match the source's pattern, a source that does not support pasting, and a request with neither `identity` nor `paste`. ### Books | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/books` | GET | any | Paginated book list. Query: `system_id`, `category`, `limit` (max 500, default 100), `offset`, `sort` (`title` - the default - or `added_at`), `order` (`asc`/`desc`; defaults to A-Z for `title` and newest first for `added_at`), `added_since` (ISO-8601; see below) | | `/api/books/:id` | GET | any | Book detail with game system. `game_system` is `{id, name, slug, is_system_agnostic, is_one_page}` (null for a book in no system); the two flags let the reader's breadcrumb prettify a special collection's folder name | | `/api/books/:id` | PATCH | gm/admin | Update: `title`, `category`, `description`, `authors`, `artists`, `genres`, `publisher`, `publisher_url` (legacy), `urls`, `isbn`, `product_code`, `version`, `language`, `license`, `year`, `month` (1–12), `day` (1–31), `tags`, `is_explicit`, `codex_id`, `access_level` (**admin only**). `license` overrides the system license for this book (blank inherits it). Changing `category` also **moves the file** - see below. `file_size`/`page_count`/`mime_type` are read-only. Sending `access_level` as a non-admin returns 403 - see [Access levels](#access-levels-issue-258). | | `/api/books/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, ...PATCH fields}]}` | | `/api/books/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/books/:id/reindex` | POST | gm/admin | Re-run OCR on a scanned book. Optional query `ocr_dpi` (72–600) re-reads this book at a higher resolution than the global `OCR_DPI`; omit for the default. Clears the book's search index and re-queues it (OCR runs in the background - poll `/api/scan-status`). 400 if the book has an embedded text layer (nothing to OCR). Returns `{status: "reindex_queued", ocr_dpi}`. | | `/api/books/:id/rescan` | POST | gm/admin | Re-read a single book from disk and rebuild its search index, for a file edited externally. Unlike `/reindex` this works for any indexable format (PDF, EPUB, DjVu, `.txt`/`.md`/`.rtf`): a text-layer book is re-extracted and its FTS rows rebuilt; an image-only PDF is re-queued for OCR. Refreshes page count and cover thumbnail if the file changed, and drops everything cached from the previous contents (page renders, open document handle, search rows). Runs in the background on the same queue as replace uploads (poll `/api/scan-status`, `kind: "reindex"`): a rescan sent while that queue is running joins it. No-ops if a library scan or OCR run is already in progress. 400 for formats that cannot be indexed (archives, images), 404 if the file is missing on disk. Returns `{status: "rescan_queued"}`. | | `/api/books/:id/file` | GET | any | Download/stream the file | | `/api/books/:id/thumbnail` | GET | any | WebP cover thumbnail. Sends an `ETag` derived from the file's content hash and honours `If-None-Match` (`304`), so a replaced cover is picked up despite the `immutable` cache policy. | | `/api/books/:id/toc` | GET | any | Table of contents as `{title, page, level, children}[]`. Available for PDF, EPUB, and DjVu; other formats 404. `page` is 1-based, or `-1` for an entry whose destination can't be resolved - a broken PDF bookmark costs only its own link, not the whole TOC, and one pointing just past the last page resolves to the last page | | `/api/books/:id/page/:num` | GET | any | Render a document page (PDF/EPUB/DjVu) as WebP. Comic archives (`.cbz`/`.cbr`/`.cb7`/`.cbt`) return the stored page image from inside the archive as-is; single-image books return the file and accept only page 1; text books 404 (no rendered page). Query: `width` (default 1200, 16-3000; outside that range is 422), `v` (the book's `content_token`, cache-busting; ignored server-side). Cached under a content-addressed key, so replacing the file supersedes earlier renders; a request for a page that is already rendering waits for that render instead of starting another. Sends an `ETag` and honours `If-None-Match` (`304`). | | `/api/books/:id/page/:num/tile` | GET | any | One zoom tile of a rendered page (PDF/EPUB/DjVu) as WebP: a square of at most 1024px from the page as if rendered `width` pixels wide, for sharp zoom past the 3000px whole-page cap. Query: `width` (required, a multiple of 256, 256-16384), `col` and `row` (required, tiles counted from the top left; a tile outside the page is 400), `v` (as for the page). Edge tiles are cut to the page. Other formats 404. Cached and revalidated like the page itself | | `/api/books/:id/page/:num/text` | GET | any | Plain text of a page (from FTS index or live extraction). Serves any indexable format - PDF, EPUB, DjVu, and `.txt`/`.md`/`.rtf` | | `/api/books/:id/page/:num/words` | GET | any | Word bounding boxes `{x0, y0, x1, y1, text}` for text overlay. Only rendered documents have page geometry; comics and text books return an empty overlay | | `/api/books/:id/page/:num/size` | GET | any | The page's `{width, height}` in PDF points (1/72 inch). Rendered documents only (PDF, EPUB, DjVu, XPS); other formats 400 | | `/api/books/:id/page/:num/shapes` | GET | any | Suggested cut-outs on a page - see [Page export](#page-export). `{shapes: [{shape, x, y, w, h}]}` | | `/api/books/:id/export` | POST | any | Export pages or cut-outs as a download - see [Page export](#page-export). Read-only, so an API key's Read scope covers it | | `/api/books/:id/export/save` | POST | admin | The same export written into a library folder instead - see [Page export](#page-export) | **Book list response:** `{"total": int, "books": [...]}` **Date added (issue #199):** every book row - here, on `GET /api/books/:id`, and in `GET /api/systems/:id` - carries `added_at`, an ISO-8601 UTC timestamp of when the book first appeared in the library. A move or an in-place file replacement keeps the original date. It is `null` for books from the library's first import (so a fresh install does not flag its whole library as new) and for legacy rows with no date to backfill; such books sort last in either direction and never match `added_since`. To poll for new additions, request `sort=added_at&added_since=` - `total` reflects the filter, so the result pages normally. A timestamp without an offset is read as UTC. The map, token, audio and 3D model list and detail rows carry `added_at` too (the map detail route does not); those galleries sort and filter on it client-side. **Access control on by-id routes:** `GET /api/books` (the library browse) is blocked for guests, but the by-id content routes (`:id`, `:id/file`, `:id/thumbnail`, `:id/toc`, `:id/page/...`) are reachable by any authenticated user and enforce access themselves. Guests may only read a book **shared into a campaign they belong to** (via a `CampaignResource` whose visibility permits them); an unshared or `gm`-only book returns 403 (404 from `GET /api/books/:id`, so its title is not disclosed). The `variants` list on `GET /api/books/:id` is trimmed for a guest to the variants they may read. For non-guests, an `is_explicit` book returns 403 from the file/page routes when the caller has `allow_explicit` disabled, and 404 from `GET /api/books/:id` (as for a restricted book, so the answer doesn't confirm it exists). With explicit content off, search leaves explicit books, tokens and models out inside its queries, and the MCP book tools answer as for a book that does not exist. A book deliberately shared into a guest's campaign is served regardless of its explicit flag (guests have no NSFW preference of their own). #### Access levels (issue #258) A book can be restricted to a minimum role, so a library shared with players can withhold the adventure module they are currently inside. Both `Book` and `GameSystem` carry an `access_level`, and a category-wide default lives in the `restricted_categories` app setting. | Value | Meaning | |---|---| | `""` | Open - every user, including players and guests | | `"gm"` | GMs and admins only | | `"admin"` | Admins only | | `null` | **Books only:** inherit (no book-level opinion) | The effective level resolves most-specific-first: **book → system → category default → open**. On a book, `null` means "inherit" and continues the cascade, while `""` is an *explicit* open that ends it - that is how a freely-shared player's guide stays visible inside an otherwise admin-only system. A system has no inherit state; it sits at the top of the cascade. `PATCH`/bulk bodies use the string `"inherit"` for the null write, because the handlers drop `null` fields so an omitted key leaves the book alone. **Effect on reads.** Restricted content is *hidden*, not locked. It is filtered out of `GET /api/books`, `GET /api/systems` (and their counts), `/api/search`, `/api/favorites`, the OPDS feeds, and bulk `/api/downloads` archives. By-id routes return **404**, not 403, so a restricted book is indistinguishable from one that does not exist and ids cannot be probed to enumerate the library. Guests are held to the player ceiling (open books only) *and* still restricted to their campaign shares. **Grants** (`/api/users/:id/access-grants`) raise one user's ceiling within one system or book. Only GMs may hold them: admins already see everything, and players and guests are exactly who restrictions exclude. **Campaigns.** A restricted book linked into a campaign is forced to `gm` visibility and cannot be made `public` or `private`; restricting a book demotes its existing shares. **Categories:** `core`, `supplement`, `adventure`, `character-sheet`, `map`, `handout`, `homebrew`, `starter-set`, `magazine` #### Metadata lookup from add-ons (issue #203) | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/books/:id/metadata-sources` | GET | gm/admin | Grimoire Codex DB (when enabled), installed, enabled add-ons that can supply book metadata, then the built-in `file-metadata` source | | `/api/books/:id/metadata-search` | POST | gm/admin | Ranked candidates. Body `{source_id, query?}` - a blank `query` defaults to the book's title | | `/api/books/:id/metadata-fetch` | POST | gm/admin | One candidate's fields, diffed against the book. Body `{source_id, identity?, query?, paste?}` | Identical in shape and semantics to the game-system endpoints above (same `status` values, same ordering, same read-only guarantee); only the target differs. Applying goes through `PATCH /api/books/:id`. `file-metadata` reads the book's own PDF info dictionary and XMP packet, EPUB OPF package, or comic archive `ComicInfo.xml`. Its search returns the file itself as the one candidate (`identity: "file"`, `url: ""`), or no results when the file carries nothing usable; it does not support `paste`. A format it cannot read (anything but PDF, EPUB, CBZ, CBR, CB7 and CBT), a missing file, or a damaged one is a `400`. A system's `metadata-search` refuses it with a `400`. Book scrapers may map: `title`, `description`, `authors`, `artists`, `publisher`, `publisher_url`, `urls`, `genres`, `isbn`, `product_code`, `version`, `language`, `license`, `year`, `month`, `day`, `tags`. **`query` on fetch:** sources that answer per query (a search endpoint) rather than serving a whole catalogue need the query to re-find the chosen candidate, so clients echo back the query the candidate came from. Catalogue-backed sources ignore it. ### Metadata lookups (genres, families, parent systems, licenses, dice/materials) Curated reference values that power the editor pickers/comboboxes and the "Metadata" settings tab (issue #202). Reads are open to any authenticated user; mutations require admin. Every list is managed in **Settings → Metadata**, where each section is collapsible. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/genres` | GET | any (not guest) | `{"genres": [{id, name, parent_id, is_default, sort_order}]}`. Tiered via `parent_id` (e.g. Cyberpunk → Science Fiction). | | `/api/genres` | POST | admin | Create a genre. Body `{name, parent_id?}`. 409 if the name exists. | | `/api/genres/:id` | DELETE | admin | Delete a genre (and its children). 409 with `{detail: {message, name, usage_count}}` if attached to a system/book, unless `?force=true`. | | `/api/system-families` | GET | any (not guest) | `{"families": [{id, name, is_default, sort_order}]}` | | `/api/system-families` | POST | admin | Create a family. Body `{name}`. 409 if the name exists. | | `/api/system-families/:id` | DELETE | admin | Delete a family. 409 if in use unless `?force=true`. | | `/api/parent-systems` | GET | any (not guest) | `{"parent_systems": [{id, name, is_default, sort_order}]}`. Empty by default (library-specific). | | `/api/parent-systems` | POST | admin | Create a parent system. Body `{name}`. 409 if the name exists. | | `/api/parent-systems/:id` | DELETE | admin | Delete a parent system. 409 if in use unless `?force=true`. | | `/api/licenses` | GET | any (not guest) | `{"licenses": [{id, name, is_default, sort_order}]}`. Seeded with common TTRPG licenses (OGL, ORC, CC-BY, Proprietary, …). | | `/api/licenses` | POST | admin | Create a license. Body `{name}`. 409 if the name exists. | | `/api/licenses/:id` | DELETE | admin | Delete a license. 409 if used by a system or book unless `?force=true`. | | `/api/dice-materials` | GET | any (not guest) | `{"dice_materials": [{id, name, group, is_default, sort_order}]}`. `group` is one of `Dice`\|`Cards`\|`Other`\|`Custom`. Sources the editor's dice/materials picker options. | | `/api/dice-materials` | POST | admin | Create a dice/material. Body `{name, group?}` (defaults to `Custom`). 409 if the name exists. The editor picker best-effort POSTs here (as group `Custom`) when an admin types a new value, so it becomes reusable. | | `/api/dice-materials/:id` | DELETE | admin | Delete a dice/material. 409 if in use unless `?force=true`. | Defaults for both tables are seeded on migration and are removable. A genre or family removed while attached to systems/books is detached from them (`?force=true`). **Variants (issues #304, #306):** a book, map, token, or audio record may be a *variant* of another — a printer-friendly cut, a gridless map, an older version. A variant is **hidden from list endpoints, counts, and search**, so one book occupies one shelf slot, but it stays fully reachable by id: `GET /api/books/:id`, its page renders, and its thumbnail all work normally. Detail responses carry `variant_parent_id`, `variant_kind`, `variant_label`, `variant_main_id` (the entry that represents the family in listings), `variant_main_kind`, `variant_main_label` and `variant_main_filename` (how the picker names that main entry, read from the parent so a response for a variant can still describe it; an empty kind and label mean "Main version"), and `variants` (the full sibling list, so a version picker needs no second request). List rows carry `variant_count`, plus `variant_kinds` on the map, token, and audio lists — the distinct kinds among the hidden versions, sorted and deduplicated, so a gallery card can name them ("Universal VTT", "Video") rather than only counting them. It is `[]` both for an item with no variants and for one whose variants carry no kind, so `variant_count` remains the fallback. Both fields come from one grouped query per page, not a lookup per row. Variants are deliberately **included** in bulk downloads and the OPDS catalogue — an archive or a feed should be complete — and in `/api/stats`'s `total_size_mb` and `library_size_mb`, since those bytes really are on disk, while the item counts exclude them. #### Page export Whole pages or areas cut out of them (issue #207), from any book PyMuPDF renders (PDF, EPUB, DjVu, XPS) and from PDF maps (`/api/maps/:id/...`, same bodies). Comic archives, text books and image maps return 400. Access is the same as reading the book: whoever can open it can export from it. `POST /api/books/:id/export` takes a JSON body: | Field | Applies to | Meaning | |-------|------------|---------| | `kind` | both | `pages` or `regions` | | `format` | both | `pdf` (default), `png`, `jpeg` or `webp` | | `dpi` | images | Render resolution, 36-600 (default 150). A PDF keeps the page's own vectors and ignores it | | `pages` | `pages` | A page list such as `"12-15, 20"`. Order is kept, repeats dropped, a backwards range read forwards | | `join_spreads` | `pages`, images | Join facing pages into one image each | | `spread_offset` | `pages`, images | How pages pair: `0` keeps page 1 alone (pairs 2-3, 4-5), `1` pairs it with page 2 - the reader's own toggle | | `regions` | `regions` | Up to 200 cut-outs: `{pages, shape, x, y, w, h, name?}` | A region's `pages` is one page, or two consecutive pages for an area across a spread. `x`/`y`/`w`/`h` are fractions (0-1) of those pages laid side by side at the first page's height. `shape` is `rect` or `ellipse`; an ellipse is transparent outside the circle (white corners in JPEG) and cannot be saved as PDF. A rectangle saved as PDF stays vector. `name` becomes the filename; without one it is ` (p<page>) <n>`. One output file is returned as itself; several come back as a zip. Filenames are sent in `Content-Disposition` with an RFC 6266 `filename*` for non-ASCII titles. Limits: images are rendered at most 100 pages per request, no single image may exceed 50 megapixels, and all the images in one request together may not exceed 250 megapixels (about 28 letter or A4 pages at 300 dpi, or 100 at 150 dpi). Each fails with a 400 whose `detail` says what to change, and the sizes are checked before anything is rendered. A PDF of pages or rectangular cut-outs copies the pages rather than rendering them, so it is held to none of these. `POST /api/books/:id/export/save` (admin) takes the same body plus `destination`, a library folder. Each file is written on its own (never a zip) through the upload path, so it is renamed rather than overwriting, refused on a read-only library, and refused for a type that collection does not index (a PDF into `tokens/`). Returns `{folder, saved: [{path, name, size}]}`; the files appear once a scan reaches that folder (`POST /api/rescan` with `scope`). `GET /api/books/:id/page/:num/shapes` suggests cut-outs by reading the page's structure: each embedded image, and each vector path drawn as a circle (a token frame). An image whose soft mask hides its corners, or a frame drawn around an image, comes back as an `ellipse`. Images stacked in the same spot (a card's art, frame and texture layers) are one piece, and a piece's own decorations - a name banner inside a card - are not offered separately. Backgrounds covering most of the page, ornaments, and panels holding several like-sized pieces are left out. When the page is mostly one picture and its structure yields nothing - a token sheet flattened into one page-sized image, or a scan - the pixels are read instead: everything that differs from the page's plain background colour is a piece, tokens touching each other are split apart, and a round piece's box is fitted to its circle. A page with no plain background (a full-bleed illustration) returns no shapes. Results are in reading order. ### Maps Archive files (`.zip`, `.rar`, `.7z`, `.tar`, `.tar.gz`, …) under `maps/`, `tokens/`, and `audio/` are indexed as opaque items in their collection and carry `is_archive: true` in both list and detail payloads. They have no thumbnail, no dimensions, and no embedded metadata - clients should offer a download rather than a preview. The comic-book extensions (`.cbz`, `.cbr`, `.cb7`, `.cbt`) are books-only and are not indexed here. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/maps` | GET | any | Paginated map list (items include `is_archive`). Query: `limit`, `offset`, `map_type`, and the [browse parameters](#browsing-the-media-collections) | | `/api/maps/groups` | GET | any (not guest) | Folders holding matching maps, with counts. Query: `map_type` and the browse filters | | `/api/maps/:id` | GET | any | Map detail: filename, tags, `map_type`, `grid_size`, `file_size`, `has_thumbnail`, `is_archive`, `is_pdf`, `page_count` (PDF maps only; `null` otherwise) | **Changing a book's category moves its file.** The folder a book sits in is what the next rescan reads, so recording a new category without moving the file would let the scan silently revert the edit. The book is moved into the sibling folder whose *inferred category* matches the new one - an existing `Rulebooks` shelf is reused rather than a second `Core` being created beside it - and the folder is created with its canonical name when nothing covers that category yet. Best-effort by design, and **never an error**: on a read-only library the category is recorded and nothing moves, because the user asked to change a category, not to move a file. The same applies to a name collision at the destination (the file lands under a suffixed name), a book outside `books/`, and a book whose file is missing. | `/api/maps/:id` | PATCH | gm/admin | Update `description`, `tags`, `map_type`, `grid_size`, and the grid override `grid_width`/`grid_height`/`grid_px` (`grid_px` is `0` to clear, or at least 4) | | `/api/maps/:id/file` | GET | any | Download/stream the original map image, PDF, or archive (served with the archive's MIME type) | | `/api/maps/:id/page/:n` | GET | any | Render page `n` of a PDF map to WebP (`width?` target pixel width, default 1600, 16-3000; outside that range is 422). Image maps stream as-is and only accept page 1; archives return 400 | | `/api/maps/:id/page/:n/size` | GET | any | A PDF map page's `{width, height}` in points. 400 for non-PDF maps | | `/api/maps/:id/page/:n/shapes` | GET | any | Suggested cut-outs on a PDF map page, as for [books](#page-export) | | `/api/maps/:id/export` | POST | any | Export PDF map pages or cut-outs, as for [books](#page-export). 400 for non-PDF maps | | `/api/maps/:id/export/save` | POST | admin | Save a map export into the library, as for [books](#page-export) | | `/api/maps/:id/export.uvtt` | GET | any | Export a map as a Universal VTT file (`application/octet-stream`, attachment named `<slug>.uvtt`). An existing `.uvtt` re-exports, carrying its own embedded image plus any edited geometry. 400 for PDF, video and archive maps, and for a raster map already **variant-linked** to a Universal VTT file | | `/api/maps/:id/otfbm` | GET | may share | How to set the map up as an [OTFBM](#otfbm-export) background: whole-cell `width`/`height` (the `-mapsize`), `cell` (the `-options c` value; `?cell=` 20-100, defaults to `suggested_cell`), the map's own `source_px`, `grid_source`, the resized `image_width`/`image_height`, and `truncated` when the map exceeds 99 cells per side. 400 for PDF, video and archive maps | | `/api/maps/:id/otfbm/image` | GET | may share | The map resized so each cell is `?cell=` pixels, as a JPEG (PNG when the map has transparency). Attachment named `<slug>-<W>x<H>c<cell>.jpg`. 400 for PDF, video and archive maps | | `/api/maps/:id/vtt/image` | GET | any | Decode and stream the image embedded in a `.uvtt`/`.dd2vtt` map. 400 if the map is not Universal VTT or carries no image | | `/api/maps/:id/vtt/data` | GET | any | Grid resolution plus wall/portal/light counts parsed from a `.uvtt`/`.dd2vtt` map, image omitted. 400 if the map is not Universal VTT | | `/api/maps/:id/vtt/authoring` | GET | any | Walls, portals, lights and environment authored in the in-app editor, plus the resolved grid to draw them on, the `image_url` to draw against, and `is_vtt`/`seeded_from_file` flags. `data` is `null` when nothing has been authored; for an unedited `.uvtt` it is seeded from the file's own geometry | | `/api/maps/:id/vtt/authoring` | PUT | gm/admin | Replace the authored geometry in one atomic write. Body: `{data: {...}}`; `{"data": null}` clears it | | `/api/maps/:id/thumbnail` | GET | any | WebP thumbnail | | `/api/maps/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, description?, tags?, map_type?, grid_size?}]}` | | `/api/maps/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/map-folders` | GET | any | List folder tag assignments | | `/api/map-folders` | PATCH | gm/admin | Set tags on a folder path. Body: `{path, tags}` | | `/api/map-folders/bulk` | POST | gm/admin | Set tags on many folders. Body: `{folders: [{path, tags}]}` | #### Map grid and Universal VTT export `GET /api/maps/:id` reports a `grid` object - `{width, height, cell_px?, source}` - holding the grid Grimoire will use for the map. `source` says where it came from, in precedence order: | `source` | Meaning | |----------|---------| | `manual` | Set by hand through the PATCH endpoint; overrides everything below | | `filename` | Parsed from a `(30x40)` style name on the file or a parent folder | | `dpi` | Derived from image DPI, one inch to a cell | | `computed` | Estimated by fitting common cell sizes against the pixel dimensions | Detection is a guess, and a wrong one is baked into every export, so the grid can be corrected: PATCH `grid_width`, `grid_height`, and optionally `grid_px`. All three are floats stored to 2 decimal places, because maps commonly bleed a partial cell past the nominal grid (a `33x24` map with a quarter-cell margin is really `33.25x24.25`). Sending `0` for a field clears the override and hands the map back to automatic detection. A grid that does not look right for the image is **saved anyway** and reported rather than rejected - unusual maps are real, and only the user can say which case applies. When the two axes disagree about the implied cell size, the PATCH response carries an advisory `grid_warning`: ```json { "status": "ok", "grid_warning": { "code": "aspect_mismatch", "cell_x": 200.87, "cell_y": 140.0, "divergence": 30.3, "suggested_width": 33.0, "suggested_height": 16.73 } } ``` `GET /api/maps/:id/export.uvtt` builds a Universal VTT file from that resolved grid: `format` 0.3, a zero `map_origin`, `map_size` in cells, `pixels_per_grid`, and the image as base64 **WebP** (which is what real exporters emit, and a fraction of PNG's size), together with whatever walls, portals and lights have been authored for the map (empty arrays when nothing has). When no grid can be resolved at all, the export falls back to 140px per cell. Exports are cached on disk and keyed on the file's mtime, the resolved grid, and the authored geometry, so editing either produces a fresh file. The file is assembled on demand and returned as a download. Nothing is written into the library: the source image is never modified and no sidecar `.uvtt` appears beside it. The response is served as `application/octet-stream` with an `attachment` filename ending in `.uvtt`, not as `application/json` - the body is JSON by format, but it is a file to save rather than an API payload, and a JSON content type makes browsers offer it as `.json`. A *raster* map that is already **variant-linked** to a Universal VTT file returns 400. That file carries real walls, doors and lights; an export would carry none, so offering it would be a downgrade presented as an upgrade. The check is on the variant link, not on filenames - the duplicate manager lets a user link two files whatever they are named - and matches either a `universal-vtt` variant kind or a sibling with a `.uvtt`/`.dd2vtt` extension. The `.uvtt` side of such a pair is exempt: it is the half holding the geometry, so exporting it is the point rather than a downgrade. Exporting a `.uvtt` re-emits its own embedded image **verbatim** - the raster lives inside the envelope as base64, so there is nothing at the path to re-encode, and passing it through keeps the exported image byte-identical to the original's. Its grid also comes from the file's own `pixels_per_grid` unless a manual override is set, since that is what the geometry inside was drawn against. #### OTFBM export `GET /api/maps/:id/otfbm` and `/otfbm/image` (issue #551) set a map up as an [OTFBM](https://otfbm.io) background for Avrae's `!map -mapsize WxH -options c<cell> -bg <url>`. OTFBM draws the background at its native resolution under a grid of `cell` pixels, without scaling it, so the image is resized by `cell / source_px` to make each of the map's cells exactly `cell` pixels. The grid is the same resolved grid the Universal VTT export uses (manual override, then detection, then 140px; a `.uvtt`'s own `pixels_per_grid`). `-mapsize` takes whole cells, so a fractional grid rounds **down** unless it is within 0.1 of the next cell: the partial-cell bleed around a printed map is not a playable square, while 32.97 is plainly 33. It is capped at OTFBM's 99 cells per side, and `truncated` reports when that cap applies. `suggested_cell` is the map's own cell size capped at 70, so the default never upscales. A grid can't claim cells under 4 source pixels: `PATCH grid_px` refuses a smaller non-zero value with `422`, a `.uvtt` claiming one is ignored, and any other route to a smaller cell is floored there. The rendered background is also capped at 16383px per side and 100 megapixels, scaled down to fit (aspect ratio kept) past either - the largest grid OTFBM can show, 99x99 cells at `c100`, is 9900px square. Unlike `export.uvtt`, a raster map variant-linked to a Universal VTT file is **not** refused: OTFBM has no walls or lights, so the linked file holds nothing this export would be missing. The image is JPEG unless the map has pixels that are actually transparent (an opaque alpha channel does not count), in which case it is PNG - OTFBM reads neither WebP nor anything without a `.png`/`.jpg`/`.gif` extension. OTFBM fetches the background anonymously, so it is served through a [share link](#share-links-1) with `variant: "otfbm"`: the link serves this resized image instead of the original map, and its `file_url` (ending in `<slug>-<W>x<H>c<cell>.jpg`) is the `-bg` URL. Both endpoints here therefore need permission to create share links (`403` otherwise, guests included); the image endpoint remains for hosting the background elsewhere. The resized image is cached on disk, keyed on the file's mtime and the planned sizes, since OTFBM re-fetches it on every render. #### Universal VTT authoring `GET`/`PUT /api/maps/:id/vtt/authoring` back the in-app editor, which lets a GM draw vision-blocking walls, place doors and windows, and position lights on a raster map. The whole document is read and replaced at once - that is what "save" means in the editor, and it keeps the write atomic. A standalone `.uvtt` map can be edited too, and opens on the geometry it already carries: when `data` has never been saved for one, the response seeds it from the file's own `line_of_sight`/`portals`/`lights`/`environment` (validated through the same gate a client payload passes) and sets `seeded_from_file`. Saving then writes to the map row as usual - **the file on disk is never rewritten**, which is what keeps a read-only library working. Because a `.uvtt` carries its picture as base64 rather than as a file, `image_url` names the decoding endpoint (`/api/maps/:id/vtt/image`) instead of the page renderer, and `pixel_width`/`pixel_height` are measured from the embedded image. Geometry is stored in **grid units**, the same unit the file format uses, so export is a copy rather than a conversion and a later grid correction does not silently move every wall. The document carries the `pixels_per_grid` it was authored against for the same reason: everything is scale-relative, so replacing the source image with a differently-sized copy would otherwise invalidate the geometry with no way to detect it. ```json { "data": { "pixels_per_grid": 140, "grid_offset": {"x": 0, "y": 0}, "line_of_sight": [[{"x": 0, "y": 0}, {"x": 10, "y": 0}]], "objects_line_of_sight": [], "portals": [ {"bounds": [{"x": 4, "y": 0}, {"x": 5, "y": 0}], "closed": true, "freestanding": false} ], "lights": [ {"position": {"x": 3, "y": 3}, "range": 4, "intensity": 1, "color": "ffeccd8b", "shadows": true} ], "environment": {"baked_lighting": false, "ambient_light": "00000000"} } } ``` Field notes, since these are where UVTT tooling conventionally goes wrong: - `line_of_sight` is an array of **polylines**, not flat segments. Each entry is one wall run; a closed room repeats its first point at the end. - `objects_line_of_sight` is kept separate from walls rather than merged. Importers treat it differently - Roll20 turns it into transparent barriers rather than solid walls - so furniture and pillars are their own layer. - A portal's `bounds` (the two endpoints of the door line) is the load-bearing field. `position` and `rotation` are **derived from it on export**, because several importers ignore one or the other and derived values cannot contradict the bounds. `closed: true` is a door, `closed: false` a window. - `color` and `ambient_light` are 8-digit **ARGB** hex with no leading `#`, alpha first (`ffeccd8b`). 6-digit RGB input is accepted and treated as opaque; anything else is rejected rather than silently defaulted. - A light's `range` is in **grid squares**, not pixels or feet. `intensity` has no scale agreed between VTTs, so it is stored and exported verbatim - an in-app preview can never match a target VTT exactly. - `environment.baked_lighting: true` means the lighting is already painted into the image, and importers may then ignore or dampen authored lights. Deliberately absent, because the format cannot carry them: wall thickness or type, blocks-movement-but-not-sight, one-way or directional walls, secret or locked doors, wall height, light animation, falloff curves, and the dim/bright radius split (importers derive that themselves). A malformed document is rejected with a 400 naming the offending field; a document with no content at all is stored as `null`. ### Tokens | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/tokens` | GET | any | Paginated token list (items include `is_archive`). Query: `limit`, `offset`, and the [browse parameters](#browsing-the-media-collections) | | `/api/tokens/groups` | GET | any (not guest) | Folders holding matching tokens, with counts. Query: the browse filters | | `/api/tokens/:id` | GET | any | Token detail incl. `is_archive` (`pixel_width`/`pixel_height` are `null` for archives) | | `/api/tokens/:id` | PATCH | gm/admin | Update `description`, `tags`, `is_explicit` | | `/api/tokens/:id/file` | GET | any | Download the token image, or the archive (served with the archive's MIME type) | | `/api/tokens/:id/thumbnail` | GET | any | WebP thumbnail | | `/api/tokens/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, description?, tags?, is_explicit?}]}` | | `/api/tokens/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/token-folders` | GET | any | List folder tag assignments. Also returns `frame_folders`: paths (relative to `tokens/`, any depth) holding a `.frames-container` marker, so the gallery can badge them. Reported separately because `folders` only covers folders that have been *tagged* | | `/api/token-folders` | PATCH | gm/admin | Set tags on a folder path. Body: `{path, tags}` | | `/api/token-folders/bulk` | POST | gm/admin | Set tags on many folders. Body: `{folders: [{path, tags}]}` | | `/api/token-frames` | GET | any (not guest) | List user-supplied token-editor frames | | `/api/token-frames/:id/file` | GET | any (not guest) | Serve one frame image | #### Token frames The token editor composites a frame over a user's art. Three defaults ship with the frontend as static assets and never touch this API; these two endpoints serve only the frames an operator has added. A frame is any `.png`, `.webp`, or `.svg` file in a folder holding a `.frames-container` marker file — the same convention `books/` uses for its container markers, so a frame folder keeps an ordinary name like `tokens/Fantasy Frames`. Frames are ordinary library files: the scanner indexes them as tokens like anything else, and the marker only declares that they are also offered as overlay art. The `id` is the base64url-encoded library-relative path. A raw path would need `%2F` escaping that proxies routinely normalise back to `/` before the router matches; the base64url alphabet contains no such character. It is unsigned because it grants nothing on its own - every request revalidates it, requiring that it decode, resolve inside the library root, sit in a folder that holds the marker and is reached through non-hidden folders, and carry an allowed extension. Any failure is a uniform 404, since a distinct 403 would confirm that a path exists. Each listed frame also carries `token_id`, the id of the `Token` row indexed from the same file, or `null` when the scanner has not reached it yet. There is no separate "frame" favourite type: because a frame image is an ordinary library file, a frame is favourited by starring its token, and this is the join that lets the editor group favourited frames. A frame with no `token_id` is still perfectly usable - it just cannot be favourited until the next rescan. Frames linked as versions of one another collapse into a single entry. The main version is listed as the frame, and the others travel with it in `variants` - each carrying its own `id`, `name`, `token_id`, `variant_kind`, and `variant_label`, so a version is composited exactly like any other frame. `variants` is always present and empty when a frame has none. A variant whose main version is *not* itself in a frames container stays listed as a top-level frame rather than disappearing, since there is nothing in the listing for it to fold into. The same `Token` join supplies this, so it costs no extra query. Listings are cached in-process for 60 seconds; files are served with the shared upload cache policy (mtime+size ETag, revalidated), so replacing a frame in place invalidates it. Frame responses also carry a restrictive `Content-Security-Policy` of their own: an `<img>`-loaded SVG cannot execute script, and this keeps the file inert even if it is navigated to directly. ### Audio Audio tracks behave like maps/tokens, with embedded metadata. Supported formats: `.mp3`, `.ogg`, `.opus`, `.flac`, `.wav`, `.m4a`, `.m4b`, `.aac`. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/audio` | GET | any | Paginated audio list (collection key `audio`). Query: `limit`, `offset`, and the [browse parameters](#browsing-the-media-collections) (`sort` also takes `track`, `title` and `duration`). Items include `duration`, `title`, `artist`, `album`, `track_number`, `disc_number`, `has_artwork`, `is_archive`. `track_number`/`disc_number` come from the file's track/disc tags and are `null` when it has none | | `/api/audio/groups` | GET | any (not guest) | Folders holding matching tracks, with counts. Query: the browse filters | | `/api/audio/:id` | GET | any | Track detail incl. `track_number`, `disc_number`, `folder_path` and `folder_tags` | | `/api/audio/:id` | PATCH | gm/admin | Update `description`, `tags` | | `/api/audio/:id/chapters` | GET | any | Chapter markers embedded in the file, read on request: `{chapters: [{start, title}]}` ordered by `start` (seconds). Reads `.m4b`/`.m4a` QuickTime chapter tracks and Nero `chpl` lists, and MP3 ID3 `CHAP` frames. `title` is `""` when the file names none. Empty for a track without chapters, an archive, or a missing file | | `/api/audio/:id/file` | GET | any | Stream/download the audio file (supports HTTP range requests), or the archive (served with the archive's MIME type) | | `/api/audio/:id/artwork` | GET | any | Track artwork, resolving a cover set through the UI first, then folder cover art, then embedded album art. 404 if none | | `/api/audio/:id/cover` | GET | gm/admin | Only a cover set through the UI, for the editor preview. 404 when the track has none, even if it has folder or embedded art | | `/api/audio/:id/cover` | POST | gm/admin | Upload a cover image (multipart `file`). PNG/JPEG/WebP/GIF, max 10 MB. Takes precedence over folder and embedded art | | `/api/audio/:id/cover/from-source` | POST | gm/admin | Set the cover from an image Grimoire already holds. Body: `{source_type, source_id}` - see [Setting an image from an existing asset](#setting-an-image-from-an-existing-asset) | | `/api/audio/:id/cover` | DELETE | gm/admin | Remove the set cover. Folder and embedded art are untouched and take over again | | `/api/audio/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, description?, tags?}]}` | | `/api/audio/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/audio-folders` | GET | any | List folder tag assignments | | `/api/audio-folders` | PATCH | gm/admin | Set tags on a folder path. Body: `{path, tags}` | | `/api/audio-folders/bulk` | POST | gm/admin | Set tags on many folders. Body: `{folders: [{path, tags}]}` | ### 3D Models Printable miniatures and terrain. Behaves like maps/tokens, with two 3D-specific fields. Supported formats: `.stl` (binary and ASCII), `.obj`, `.ply`, `.3mf`, `.glb`, `.gltf`, and the sliced resin formats `.lys`, `.ctb`, `.cbddlp`, `.pwmx`, `.photon`. Only `.stl` produces a server-rendered thumbnail; the rest are stored and served without one. `is_supported` is **tri-state**: `true` presupported, `false` unsupported, `null` when the scan could not tell from the filename or folder. List rows flatten it into the two booleans `is_presupported`/`is_unsupported` (both `false` when unknown) so a UI badge cannot misreport an unclassified model. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/models` | GET | any | Paginated model list (collection key `models`). Query: `limit`, `offset`, and the [browse parameters](#browsing-the-media-collections). Items include `triangle_count`, `is_presupported`, `is_unsupported`, `is_archive` | | `/api/models/groups` | GET | any (not guest) | Folders holding matching models, with counts. Query: the browse filters | | `/api/models/:id` | GET | any | Model detail incl. `folder_path`, `folder_tags`, `is_supported`, `viewer_loader`, `viewer_available`, `viewer_oversized` | | `/api/models/:id` | PATCH | gm/admin | Update `description`, `tags`, `is_explicit`, `is_supported` | | `/api/models/:id/file` | GET | any | Download the mesh file (served with the format's MIME type), or the archive | | `/api/models/:id/thumbnail` | GET | any | WebP thumbnail. 404 for a format with no server-side renderer | | `/api/models/bulk` | POST | gm/admin | Bulk update. Body: `{items: [{id, description?, tags?, is_explicit?, is_supported?}]}` | | `/api/models/bulk/tags` | POST | gm/admin | Bulk **add** tags. Body: `{ids, tags}` | | `/api/model-folders` | GET | any | List folder tag assignments | | `/api/model-folders` | PATCH | gm/admin | Set tags on a folder path. Body: `{path, tags}` | | `/api/model-folders/bulk` | POST | gm/admin | Set tags on many folders. Body: `{folders: [{path, tags}]}` | `viewer_loader` names the client-side loader for the format (`stl`, `gltf`, `3mf`, `ply`) and is `""` when none applies. `viewer_available` is `false` both for those formats and for a mesh over 256 MiB, which is large enough that loading it unprompted could exhaust the tab. `viewer_oversized` tells the two cases apart. When it is `true` the format is supported and only the size cap held it back, so the client offers to load it anyway behind a warning that it may be slow or unresponsive; when it is `false` alongside a `false` `viewer_available`, no loader exists for the format and a download is the only option. #### Browsing the media collections The map, token, audio and model galleries filter, sort and group on the server and fetch a page at a time (issue #221), so a library of a few hundred thousand files opens as quickly as a small one. Each list route and its `/groups` companion take the same filters, so a folder's count always matches what listing that folder returns: | Parameter | Meaning | |-----------|---------| | `q` | Search text: the name, the folder path, and the item's tags (its own and every folder's above it). Accepts the [search page's `field:value` prefixes](#field-scoped-search) - `tag:`, `title:`/`filename:`, and `artist:`/`album:` on audio. A book-only field (`author:`, `year:`...) matches nothing | | `tags` | The tag filter as JSON: a list of `{mode: "include"\|"exclude", tags: [...]}` groups - tags OR'd within a group, groups AND'd - or a flat list of tags that must all match. A folder's tags count for everything beneath it. `__grim:none__` / `__grim:any__` match items with no tags / at least one. Malformed JSON is a 400 | | `favorites` | `true` for the caller's favourites only | | `added_since` | Only items added at or after this ISO time; undated items never match | | `folder` | Only items directly in this folder, collection-relative (`""` for the collection root). Omit for the whole collection | | `sort` | `path` (folder order, the default), `name` (natural order: "Map 2" before "Map 10"), `size`, `added_at` (undated items last either way); audio also `title`, `duration` and `track` (album order: folder, then disc and track number, then name - untagged files first, in name order; on other collections the same as `path`). Any other value is a 422 | | `order` | `asc` or `desc` | `GET /api/<collection>/groups` returns `{total, groups: [{path, count}]}`: every folder holding matching items, collection-relative (`""` for the root). The gallery draws its folders from this and opens each with the list route's `folder` parameter; ungrouped, it pages the flat list instead. #### Browsing a system's shelf `GET /api/systems/:id/books` and `/book-groups` take the shelf's filters: `q` (title, filename or product code, plus the `field:value` prefixes such as `author:`, `year:`, `tag:`, `code:`), `tags` (as above, matching the book's own tags), `favorites`, `added_since`, `explicit` (bool), `genre` (a genre or a presence sentinel) and `product_code` (a code prefix such as `PZO`, or a sentinel). Books the caller may not open are left out of pages and counts alike. **Access control on media by-id routes:** As with books, the library-browse list routes (`GET /api/maps`, `/api/tokens`, `/api/audio`, `/api/models` and their `*-folders`) are blocked for guests, but the by-id routes (`:id`, `:id/file`, `:id/thumbnail`, `:id/artwork`) are reachable by any authenticated user and enforce access themselves. A guest may only read a map/token/audio/model item **shared into a campaign they belong to** (via a `CampaignResource` whose visibility permits them); otherwise the route returns 403. The `variants` list on each detail route is trimmed for a guest to the variants shared with them. An explicit token returns 403 for a non-guest who has `allow_explicit` disabled, on the file/thumbnail routes as well as `GET /api/tokens/:id`. An item deliberately shared into a guest's campaign is served regardless of its explicit flag. ### Favorites | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/favorites` | GET | any | List current user's favorites with enriched detail. For a guest, `items` holds only books and media shared into their campaign - favorites of anything else, and of systems and tags, are left out | | `/api/favorites` | POST | any | Add a favorite (idempotent). Body: `{item_type, item_id}` | | `/api/favorites/:type/:id` | DELETE | any | Remove a favorite (silent 204 if not found) | Item types: `book`, `map`, `token`, `audio`, `model`, `system`, `tag` (a `tag` favorite's `item_id` is the tag's internal key; it enriches to `{internal, display, count}`). ### Tags Application-wide tags shared across systems, books, maps, tokens, audio, and 3D models. Each tag has a lowercased **internal** key (used for matching/dedup) and an editable **display** value (the casing first entered). The internal key normally stays put, but a rename that changes the display's normalized form re-keys it (a typo fix like `freinds` → `friends`), merging into an existing tag if one already owns the new key. A resource's tags are set through its own update endpoint (e.g. `PATCH /api/books/:id` with `tags`); these endpoints manage the shared tag catalog and browse items by tag. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/tags` | GET | any (not guest) | List tags with usage `count` and `is_favorite` (for the current user). Query `in_use_by=system\|book\|map\|token\|audio` restricts to tags used on that resource type. Folder tags (from `tags.json`/folder tagging, including **book subcategory folders**) are merged in and counted by the items they cover | | `/api/tags` | POST | gm/admin | Create a tag up front (idempotent by internal key). Body: `{value, display?}`. A `value` containing `/` or `\` is refused with a 422 (see **Tag names** below) | | `/api/tags/:internal/items` | GET | any (not guest) | A tag's summary plus one page of the items carrying it directly: `counts` (directly-tagged items per type), `total`, `items` (a page, in type order then name order, enriched like favorites), and `folders` - every folder carrying the tag as `{resource_type, path, key, count}` (book folders show only their subfolder path). Query: `resource_type` (one type's counts, folders and page), `limit` (default 100, max 500; `0` for the summary alone), `offset`. Counts and pages leave out explicit items for users who can't see them, and books or systems the caller may not open | | `/api/tags/:internal/folder-items` | GET | any (not guest) | A page of everything inside one tagged folder, in name order: `{total, items}`. Query: `resource_type`, `folder` (the group's `key`), `limit`, `offset`. 404 when that folder does not carry the tag | | `/api/tags/:internal` | PATCH | gm/admin | Rename a tag's display value; when the new display normalizes to a different key the internal is re-keyed too (merging into an existing tag on collision). Works for **folder-only** tags too (a tag that lives only in folder JSON is materialised into a catalog row so the rename persists - no 404). Body: `{display}`. A `display` containing `/` or `\` is refused with a 422 | | `/api/tags/:internal/merge` | POST | gm/admin | Merge this tag into another, re-pointing all links. Body: `{into}`. `into` is refused with a 422 if it contains `/` or `\`; the **source** `:internal` may contain one, so merging is a way out of such a tag | | `/api/tags/:internal` | DELETE | gm/admin | Delete a tag and unlink it from every resource | **Tag names.** A tag's `internal` key is its address in these paths, so a name containing `/` or `\` is rejected (422) by every endpoint that accepts one — here and on every `tags` field of the resource editors, which share the same validation. A slash there is not a subtag (Grimoire has no such concept); it is a flat name that used to make its own tag unaddressable and so impossible to rename or delete (issue #430). The four `:internal` routes above match **across slashes**, so a tag that already carries one — created before the rule, or applied from a `tags.json`, which is the user's own file and is still honoured as written — can still be fetched, renamed, merged, and deleted. Percent-encode the key (`storage%2Fbox1`) as `encodeURIComponent` does. **What `count` counts.** `count` is the number of *items carrying the tag* — the same population `GET /api/tags/:internal/items` returns, so the two always agree (issue #445). Specifically it counts rows that still exist and that the library actually browses: a link whose resource has since been deleted is not counted, and a file [filed under a parent as a variant](#duplicates-admin-only) is counted once, under its parent, exactly as it is hidden in listings and search. A tag whose every carrier is gone reports `count: 0` rather than a figure with nothing behind it. Folder-derived coverage is added on top, by the items each tagged folder covers. Tag object shape: `{internal, display, category}` (list/item endpoints also include `count`; the list adds `is_favorite`). A tag's **category** is the single resource type it is used on (`system`/`book`/`map`/`token`/`audio`), or `shared` once it spans more than one type. The reported category is *effective*: it reconciles the tag's stored category (direct usage) with every resource type it appears on via folder tags, so a tag on a book plus a map folder resolves to `shared` even though folder tags never rewrite the stored row. Migrated tag displays default to Title Case; users favorite tags via the favorites endpoints (`item_type: "tag"`). Resource types: `system`, `book`, `map`, `token`, `audio`, `model`. Book subcategory folders are tagged via `PATCH /api/systems/:id/book-folders` (path `{system_id}/{category}/{subfolder…}`) and surface on the tags page under **Books**, alongside the `tags.json`-style folder tags on maps/tokens/audio/models. **Folder tags** (the JSON `tags` lists on `*_folders`/`book_folders`) store tag **internal keys**; their display casing comes from the tag catalog (folder read endpoints resolve them back to display strings). Saving folder tags registers a catalog row for each (new tags take the entered casing as their default display; existing tags keep theirs). Because the library is read-only, `tags.json` is an **additive** input: on rescan it only *adds* tags - it never removes a folder or item tag set/removed in the UI, and never overwrites an existing tag's display. ### Saved filters Per-user named sort/filter presets for a library scope. At most one preset per (user, scope) may be the **default** - the view the user lands on. Setting a preset default clears the flag on any sibling in the same scope. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/saved-filters` | GET | any | List the user's saved filters. Optional query `scope` limits to one scope. Returns `{filters: [{id, scope, name, state, is_default}]}` | | `/api/saved-filters` | POST | any | Create a preset. Body `{scope, name, state, is_default?}`. Re-saving an existing `(scope, name)` overwrites its `state`. | | `/api/saved-filters/:id` | PATCH | any | Rename, replace `state`, and/or set as the scope default. Body `{name?, state?, is_default?}` | | `/api/saved-filters/:id` | DELETE | any | Delete one of the user's saved filters | Scopes: `systems`, `books`, `maps`, `tokens`, `audio`, `models`. `state` is an opaque sort/filter object the client interprets (e.g. `{sort, order, filters}`). ### Saved audio sets Per-user named playlists and soundboards. The live player queue and the live soundboard stay in browser storage; a saved set is an explicit copy kept server-side, so it follows the user across devices. Both kinds share one collection, discriminated by `kind` (`playlist` | `soundboard`). `entries` is the ordered list of tracks/pads; `layout` is the soundboard grid and is `null` for a playlist. `(user, kind, name)` is unique, so re-saving a name updates that set rather than adding a second one, while a playlist and a soundboard may share a name. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/audio-sets` | GET | any | List the user's saved sets. Optional query `kind` limits to one kind. Returns `{sets: [{id, kind, name, count, layout, updated_at}]}` - names and sizes only, not entries | | `/api/audio-sets` | POST | any | Save a set. Body `{kind, name, entries: [{audio_id, loop?}], layout?: {cols, rows}}`. Re-saving an existing `(kind, name)` overwrites its entries and layout. | | `/api/audio-sets/:id` | GET | any | Load one set, resolved against the current library | | `/api/audio-sets/:id` | PATCH | any | Rename a set and/or replace its contents. Body `{name?, entries?, layout?}`. A name already used by another set of the same kind returns `409`. | | `/api/audio-sets/:id` | DELETE | any | Delete one of the user's saved sets | Entries store audio ids only. `GET /api/audio-sets/:id` pairs each id with the track's **current** metadata, so a title edited in the library shows through on every set referencing it: ```json { "id": "…", "kind": "soundboard", "name": "Tavern", "entries": [ {"audio_id": "…", "loop": true, "title": "Hearth", "artist": "", "has_artwork": false} ], "missing": 1, "layout": {"cols": 3, "rows": 5}, "updated_at": "2026-09-09T00:00:00" } ``` An entry whose track has left the library - or is no longer shared with a guest - is omitted from `entries` and counted in `missing`, so a stale set degrades to what is still playable instead of failing the load. The listing's `count` is of *saved* entries, so it can exceed the number that resolve. Limits: `name` must be non-blank (max 120 chars), a set holds at most 500 entries, and `layout` is bounded by the soundboard grid (`cols` 1-8, `rows` 1-15). A `layout` sent with a playlist is dropped. ### Bookmarks Bookmarks are per-user - users cannot see or modify each other's bookmarks. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/bookmarks?book_id=` | GET | any | List user's bookmarks for a book, sorted by page | | `/api/bookmarks` | POST | any | Create a bookmark. Body: `{book_id, page_number, label?, notes?, selected_text?}`. `404` when the book doesn't exist or the caller can't read it. | | `/api/bookmarks/:id` | PATCH | any | Update `label` or `notes` | | `/api/bookmarks/:id` | DELETE | any | Delete a bookmark | `selected_text` is `null` for page bookmarks; non-null for text-selection bookmarks. ### Search | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/search?q=` | GET | any | FTS5 full-text search **plus** metadata search. Required: `q` (min 2 chars). Optional: `book_id`, `system_id`, `limit` (default 20). Supports `field:value` filters (see below). Global search also matches books by title/metadata, and maps, tokens, audio, and 3D models by filename/folder/tag (audio additionally matches embedded title/artist/album). | | `/api/search/fields` | GET | any | The `field:` prefixes the search box accepts, with their aliases. Powers the in-app syntax help. | **Response:** ```json { "query": "fireball", "total": 42, "results": [{"id": "uuid", "title": "...", "game_system": "...", "page_number": 42, "snippet": "...", "category": "core", "content_token": "a1b2c3d4", "mime_type": "application/pdf", "page_count": 280}], "book_matches": [{"id": "uuid", "title": "...", "game_system": "...", "game_system_id": "uuid", "category": "core", "authors": [...], "publisher": "...", "year": 2022, "page_count": 280, "has_thumbnail": true, "tags": [...]}], "maps": [{"id": "uuid", "filename": "...", "relative_path": "...", "has_thumbnail": true, "tags": [...]}], "tokens": [{"id": "uuid", "filename": "...", "relative_path": "...", "has_thumbnail": true, "tags": [...]}], "audio": [{"id": "uuid", "filename": "...", "relative_path": "...", "title": "...", "has_thumbnail": true, "tags": [...]}], "fields": ["title"] } ``` `maps` and `tokens` are empty when `book_id` or `system_id` is scoped. Each page hit in `results` carries its book's `content_token`, `mime_type` and `page_count`, the same values as `GET /api/books/{id}`. The search view uses them to start loading the page image the reader will ask for while a hit is hovered, at the reader's exact URL (`?width=...&v=<content_token>`), and to skip that for text documents, which have no page images. `results` holds **page** hits (one entry per matching page). `book_matches` holds **books** matched on their own metadata — title, author, publisher, system, category, tags, identifiers — with no page number or snippet, so a plain title search answers "do I own this?" without wading through page text. It is empty when scoped to a single `book_id`. `fields` echoes the canonical names of the `field:` filters the server recognised in `q`. #### Field-scoped search A term may be prefixed with a field name to search only that field: | Field | Aliases | Matches | |-------|---------|---------| | `title` | `name` | Book titles; map/token filenames; audio track titles | | `author` | `authors` | Book authors | | `artist` | `artists` | Book artists; audio artist | | `publisher` | — | Book publisher | | `system` | `game` | Game system name or slug | | `category` | — | Book category (`core`, `supplement`, …) | | `tag` | `tags` | Tags on books, maps, tokens, audio, and 3D models | | `year` | — | Publication year: `year:2015`, `year:>2015`, `year:<=2020`, `year:2015-2020` | | `isbn` | — | Book ISBN | | `code` | `sku`, `product_code` | Book product code (publisher's catalogue number); spaces and hyphens ignored on both sides | | `language` | `lang` | Book language | | `description` | `desc` | Book description | | `album` | — | Audio album | | `filename` | `file` | Filename on disk | | `text` | `content`, `page` | Page content (FTS5) | Rules: * **A metadata filter suppresses content search.** `title:avatar` searches titles and returns an empty `results`; `text:`/`content:` forces the page-text search back on, and combines with any leftover free text. * **Filters are ANDed; repeated values for one field are ORed.** `system:pbta category:core` narrows, `tag:forest tag:swamp` widens. * **Quoted phrases** (`author:"Gary Gygax"`, single or double quotes) keep spaces together. * **Book-only fields suppress the media sections.** `author:`, `publisher:`, `category:`, `year:`, `isbn:`, `code:`, `language:`, `description:`, and `text:` cannot describe a map, so `maps`/`tokens`/`audio` come back empty rather than unfiltered. * **Unknown prefixes are searched literally.** A colon is ordinary punctuation in a title, so `Vaesen: Nordic Horror` searches for that text rather than erroring. User input is quoted into FTS5 phrases before it reaches `MATCH`, so punctuation that FTS5 reserves as operator syntax (`D&D`, `Edge-of-the-Empire`, an unbalanced paren) is searched literally instead of returning a 500. Well-formed boolean operators still work — `fireball OR lightning` — while a dangling one (`trailing AND`) is treated as a literal term. **Variants and search:** global and system-scoped search return only variant parents, so one book yields one result rather than five. A consequence worth knowing: text that exists *only* in a variant — errata added in a v1.0.1, or the OCR'd copy of a scan — is not findable from global search. Scoping with `book_id` searches that specific record, variant or not, which is how the reader searches inside whichever version is open. Each result's `snippet` is HTML-safe: the matched term is wrapped in `<mark>…</mark>` and all surrounding text (which originates from an untrusted PDF text layer) is HTML-escaped server-side, so the client can render it via `dangerouslySetInnerHTML` without risking markup injection. ### Campaigns #### Campaign CRUD | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns` | GET | any | List own + invited campaigns (admins see only their own here). Each item includes `has_banner`, `next_session` (next scheduled date or null), `last_accessed_at`, `is_archived`, and `archived_at`. Archived campaigns are omitted unless `?include_archived=true`, which returns archived campaigns *alongside* active ones (not archived-only). | | `/api/campaigns` | POST | any (gm/admin for `is_gm_campaign: true`) | Create campaign. Body: `{name, description?, is_gm_campaign?, gm_title?, system_id?, system_name?, parent_campaign_id?, resources?}`. `description` accepts markdown. `system_name` is free text for a system not in the library (ignored when `system_id` is set). `resources` is an explicit list of `{resource_type, resource_id, visibility?, shared_user_ids?}` to link - omit it (or send `[]`) to link nothing. No resources are auto-added. Returns 403 if the user's `campaign_access` is disabled, and 404 if `system_id` names a system the caller can't see (missing, above their access level, or explicit with explicit content off). | | `/api/campaigns/:id` | GET | owner or member | Campaign detail with members and resources. The `resources` array is filtered by the caller's visibility (same rule as `GET /api/campaigns/:id/resources`): members never receive `gm`-only or unshared-`private` resource ids. Includes `has_banner`, `is_archived`, `archived_at`, and `locked` (`true` when the campaign is archived **or** the owner's `campaign_access` is disabled - the campaign is then read-only for everyone, write endpoints return 409 for archived / 403 for disabled access, and members keep read access) plus `owner_has_campaign_access` (which stays `true` for a merely-archived campaign, so the two causes are distinguishable). Each member includes `id`, `has_art`, `has_sheet`, `character_sheet_filename`, and `campaign_access` (false → flagged as a disabled user). A guest member's `guest_code` is returned to the owner only; everyone else gets `null`. A linked game system the caller can't see is left out: `system_id` is `null` and `system_display_name` empty for them, here and in the campaigns list. Opening this endpoint records `last_accessed_at` (drives recently-accessed sorting on the campaigns list). | | `/api/campaigns/:id` | PATCH | owner | Update `name`, `description` (markdown), `gm_title`, `system_id`, `system_name`, `parent_campaign_id`. Setting `system_id` clears `system_name` and vice-versa (`system_name: ""` clears it). A new `system_id` must be a system the caller can see, else 404. | | `/api/campaigns/:id` | DELETE | owner | Delete campaign and all related data. Its guests' accounts go with it, except a merged guest still in another campaign. Characters played in it stay with their players, no longer linked to a campaign. Admins delete via the database directly. | | `/api/campaigns/:id/convert-to-group` | POST | owner (gm/admin) | Promote a personal campaign to a GM-run group campaign, unlocking members, guests, and the schedule. Body: `{gm_title?}` (blank/omitted keeps the current title). Nothing is migrated - the campaign's existing resources, wiki, and sessions carry over untouched and the member list starts empty. **One-way**: there is no group → personal route. Returns 409 if the campaign is already a group campaign, 403 if the caller is not a gm/admin. | | `/api/campaigns/:id/archive` | PUT | owner | Archive or unarchive a campaign. Body: `{archived: true|false}`. Archiving hides it from every member's campaign list (unless `?include_archived=true`) and freezes it read-only: all write endpoints return 409 until it is unarchived, while reads and `DELETE` still work. Sets/clears `archived_at`. | | `/api/campaigns/admin/by-user/:user_id` | GET | admin | Read-only minimal list of a user's campaigns (`id`, `name`, `description`, `is_gm_campaign`, `system_id`, `system_name`) for the user-management page | | `/api/campaigns/invites` | GET | any | The current user's pending invitations (members in `invited` status). Returns a list of `{campaign_id, name, description, owner_display_name}`. Invitations to archived campaigns are omitted (they reappear, still pending, if the owner unarchives). Powers the app-level invite banner. | #### Members | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/invite` | POST | owner | Invite a user. Body: `{user_id}`. GM campaigns only. Returns 403 if the target user's `campaign_access` is disabled. | | `/api/campaigns/:id/members/:user_id` | PATCH | member (own) or owner | Accept/decline or set character name. Body: `{status?, character_name?}`. A user with `campaign_access` disabled cannot `accept` (403) but may `decline`. | | `/api/campaigns/:id/members/:user_id` | DELETE | owner or self | Remove member. A member removing **themselves** works even on an archived campaign (nobody is held in a campaign because its GM archived it); the owner removing *someone else* is a roster edit and returns 409 while archived. | | `/api/campaigns/:id/eligible-members` | GET | owner | Users eligible to be invited (each includes `campaign_access`) | Member statuses: `invited` → `accepted` or `declined` #### Guests Guests are code-only accounts (role `guest`) scoped to a single GM campaign. All endpoints require the campaign owner and a server with guest access enabled (the global `guest_access_enabled` setting, or the `GUEST_ACCESS_ENABLED` env pin); otherwise they return 403. Guest members appear in the campaign-detail member list flagged with `is_guest: true`. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/guests` | POST | owner | Create a guest. Body: `{nickname}`. Returns `{id, user_id, nickname, guest_code, status, ...}` with a unique 10-char code. GM campaigns only (400 otherwise). | | `/api/campaigns/:id/guests` | GET | owner | List the campaign's guests with their codes. | | `/api/campaigns/:id/guests/:member_id/regenerate` | POST | owner | Issue a new code for a guest, invalidating the old one. | | `/api/campaigns/:id/guests/:member_id/share-template` | GET | owner | Returns share content: `{code, link, message, mailto_url, discord_message}`. `link` is a `/guest?code=…` deep link built from `BASE_URL`. | | `/api/campaigns/:id/guests/:member_id` | DELETE | owner | Remove a guest; also deletes the backing guest account and its contributions, unless an admin merged that guest into other campaigns it is still in - then only this membership goes and the rest of the account stays. If this membership held the guest's invite code, a membership that remains gets a freshly generated code (the removing GM knew the old one, which stops working); when the guest leaves on their own through `DELETE /members/:user_id`, their existing code moves across instead. Deleting the campaign, or the GM's account, works like removal. Existing guest sessions carry on either way. | Guests authenticate via [`/api/auth/guest-login`](#auth) and may write only their own character name, art, sheet, session notes, and availability - everything else is read-only. The shared library, maps, tokens, and search return 403 for guests. #### Setting an image from an existing asset Three targets - the campaign banner, a game system's cover, and an audio track's cover - can be set from an image Grimoire already holds instead of a fresh upload from the user's device, via a `POST .../from-source` endpoint taking `{source_type, source_id}`. | `source_type` | Resolves to | Available on | |---------------|-------------|--------------| | `map` | The map image itself, or its generated thumbnail for a PDF/archive map | all three | | `token` | The token image, or its thumbnail | all three | | `book` | The book's cover thumbnail | all three | | `audio` | The track's folder or embedded artwork | all three | | `campaign_file` | An image uploaded to the campaign (`is_image`) | banner only | The chosen bytes are **copied** into the target's own storage exactly as an upload is, so the result survives the source asset being deleted or the library being reorganised, and the GET endpoints are unchanged. `campaign_file` is campaign-scoped and is rejected (422) on the system and audio endpoints, which have no campaign context to resolve it against. Authorisation is enforced twice: the caller must be allowed to write the target (campaign owner, or gm/admin for a system/track) **and** must be able to read the source in its own right, under the same rules its own content route applies. The picker therefore grants no access the caller did not already have. Unknown source types are rejected with 422; a source that is missing, or that has no image to give (a book with no cover thumbnail, a track with no artwork), returns 404. A `campaign_file` must decode as PNG, JPEG, WebP or GIF, like an upload (400 otherwise); library sources keep any image format the library supports. #### Banner, character art & sheets Files are stored on disk under `DATA_PATH/campaign_uploads/`. Banners are keyed by campaign id; character art and sheets are keyed by the **CampaignMember id** (`member.id` from the campaign-detail response), so a player in multiple campaigns gets a distinct file per membership. Image uploads (banner, art) accept PNG/JPEG/WebP/GIF up to 5 MB; sheets additionally accept PDF up to 15 MB. The bytes themselves must decode as one of those formats - a file in another format (TIFF, BMP, PPM, ...) is refused with 400 whatever `Content-Type` it is sent with. The same holds for every other image upload: system and audio covers, character portraits and image fields, and the OIDC button icon. Serving endpoints authenticate via the `grimoire_session` cookie for use in `<img>`/download URLs (see [Authentication](#authentication)). The GET (serving) endpoints for banners, art, sheets, and campaign files (`/files/:file_id`) set `Cache-Control: private, max-age=300, must-revalidate` along with `ETag`/`Last-Modified` validators derived from the file's mtime + size. A conditional request (`If-None-Match`/`If-Modified-Since`) with a matching validator returns `304 Not Modified`; re-uploading a file changes its validator so clients refetch. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/banner` | POST | owner | Upload/replace campaign banner (multipart `file`). A downscaled (≤1000px-wide WebP) copy is generated for fast display alongside the stored original. | | `/api/campaigns/:id/banner` | GET | member or owner | Banner image. Defaults to the downscaled WebP; pass `?size=full` for the original upload. The small copy is generated on first access for banners uploaded before downscaling existed. | | `/api/campaigns/:id/banner` | DELETE | owner | Remove banner. Also resets `banner_focus_y` to 50, so a stale focal point never carries onto the next banner | | `/api/campaigns/:id/banner/from-source` | POST | owner | Set the banner from an image Grimoire already holds. Body: `{source_type, source_id}` - see [Setting an image from an existing asset](#setting-an-image-from-an-existing-asset) | | `/api/campaigns/:id/banner/focus` | PUT | owner | Set where the banner sits vertically in the 2:1 hero. Body: `{focus_y}`, 0-100 (50 = centred). Returns `{banner_focus_y}` | | `/api/campaigns/:id/members/:member_id/art` | POST | member (own) or owner | Upload/replace character art (multipart `file`) | | `/api/campaigns/:id/members/:member_id/art` | GET | member or owner | Character art image | | `/api/campaigns/:id/members/:member_id/art` | DELETE | member (own) or owner | Remove character art | | `/api/campaigns/:id/members/:member_id/token` | POST | member (own) or owner | Upload/replace the character's VTT token (multipart `file`) | | `/api/campaigns/:id/members/:member_id/token` | GET | member or owner | Character token image | | `/api/campaigns/:id/members/:member_id/token` | DELETE | member (own) or owner | Remove the character token | | `/api/campaigns/:id/members/:member_id/sheet` | POST | member (own) or owner | Upload/replace character sheet (multipart `file`) | | `/api/campaigns/:id/members/:member_id/sheet` | GET | member or owner | Download character sheet (original filename) | | `/api/campaigns/:id/members/:member_id/sheet` | DELETE | member (own) or owner | Remove character sheet | | `/api/campaigns/:id/members/:member_id/sheet/duplicate` | POST | member (own) or owner | Copy a blank PDF into the member's sheet (body `{ source_type: "book"\|"file", source_id }`) | | `/api/campaigns/:id/sheet-sources` | GET | member or owner | List duplicatable blank sheets (`{ books, files }`): library `character-sheet` PDFs (filtered to the campaign's system when set, and to books the caller may read - a guest sees only books shared into the campaign) and campaign PDF files whose resource the caller can see. `sheet/duplicate` accepts only a source this list offers the caller, else 404 | A member's **token** is stored separately from their **art** rather than reusing the same column: the art is a portrait for the campaign page, the token is the cropped disc that goes on a battlemap, and a character routinely has one without the other. Both are keyed by `CampaignMember` id and carry the same permission rule — the member themselves or the campaign owner. The member payload exposes `has_art` and `has_token` independently. Neither writes to the shared library. The token editor composes in the browser and must keep working against a read-only library, so a character token is campaign data (stored under `DATA_PATH/campaign_uploads/tokens/`), not a library `Token` row. A GM adding a token to a campaign's *resources* instead goes through `POST /api/campaigns/:id/images`, which stores it as a campaign file and links it as a `file` resource under an optional category. #### Resources | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/resources/search` | GET | any | Search books/maps/tokens/audio. Matching runs in SQL across the whole library. Books match on title, relative path, category, or game-system name (filter with `system_id?`); maps/tokens/audio match on filename or relative path, and audio also on its title. Results are ranked with folder-path matches above name-only matches. A `system_id` naming a container system (issues #261/#262) also matches its child systems' books, since a container holds none of its own. Each result's `subtitle` is its folder-tree path, letting the picker build a nested tree: for books this is `<System>/<category>/<subcategory>/…` (the game system leads; falls back to `<System>/<category>` when the book sits directly in the system dir); for media it is the folder path under the top-level media dir. Query: `q`, `resource_type?`, `system_id?`, `limit?` (default 5000, clamped to 20000) - `limit` applies **per resource type**, so requesting several types does not shrink each one's share. Results are main versions only - one row per item, not one per cut of it - and each map/token/audio hit carries its other versions in `variants` (`resource_id`, `name`, `has_thumbnail`, `variant_kind`, `variant_label`), empty when it has none. Campaign resource linking ignores the field; it exists for pickers that *compose* with the file, such as the token editor, where which version becomes the token is a real choice | | `/api/campaigns/resources/suggested/:system_id` | GET | any | Books in a game system for the create wizard. Core-category books are flagged `suggested` and ordered first. | | `/api/campaigns/:id/resources` | GET | member or owner | List linked resources (each with `visibility`, `category_id`, `sort_order`, `has_thumbnail`; owner items also include `shared_user_ids`). Ordered public → private → gm, then by `sort_order`. Players see only what their visibility allows. | | `/api/campaigns/:id/resources` | POST | owner | Link a resource. Body: `{resource_type, resource_id, visibility?, shared_user_ids?, category_id?}` | | `/api/campaigns/:id/resources/bulk` | POST | owner | Link many resources at once. Body: `{resources: [{resource_type, resource_id, visibility?, ...}]}`. Duplicates/unknown types are skipped; returns the rows created. | | `/api/campaigns/:id/resources/reorder` | PUT | owner | Drag-and-drop order. Body: `{ordered_ids}` | | `/api/campaigns/:id/resources/:res_id` | PATCH | owner | Update visibility/shares/category. Body: `{visibility?, shared_user_ids?, category_id?}` (each optional; `category_id: ""` clears it) | | `/api/campaigns/:id/resources/:res_id` | DELETE | owner | Unlink resource (deletes the underlying file for `file` resources) | | `/api/campaigns/:id/files` | POST | owner | Upload a campaign file (multipart `file`); links it as a `file` resource. Optional `category_id` / `new_category_name` form fields file it under a resource category in the same call. Subject to admin upload limits (admins exempt). | | `/api/campaigns/:id/images` | POST | owner | Upload an image (multipart `file`, image types only) to embed in a wiki note; links it as a `file` resource with `is_image: true`. Optional multipart fields: `category_id` (file it under an existing resource category) or `new_category_name` (create a category and file it there). | | `/api/campaigns/:id/files/:file_id` | GET | per visibility | Download a campaign file (honours the linking resource's visibility); for an image this also serves it inline/as its thumbnail | Resource types: `book`, `map`, `token`, `audio`, `model`, `file` (a GM-uploaded file stored under `DATA_PATH/campaign_uploads/files/`, separate from the library). Listed/serialized resources include `is_image` (true for `file` resources that hold an image upload - those render inline with a thumbnail instead of as a download card). Resource **visibility** is one of: `public` (every accepted member), `private` (the owner plus the users in `shared_user_ids`), or `gm` (owner only). The character-sheet upload endpoints also accept a URL alternative via the member PATCH: `PATCH /api/campaigns/:id/members/:user_id` with `{character_sheet_url}` (`""` clears it; setting a URL clears any uploaded sheet, and uploading a sheet clears the URL). The URL must be `http://` or `https://`. App-wide admin settings gate campaign file uploads (admins are exempt): `campaign_uploads_disabled` (bool), `campaign_upload_max_file_mb` (int, 0 = unlimited), `campaign_upload_max_total_mb` (int, 0 = unlimited). They are settable via `PATCH /api/settings` and exposed on `GET /api/settings/ui`. #### Entry icons Wiki pages and campaign categories each carry an optional `icon` and `icon_color`, used to make entries distinguishable at a glance in the campaign tree. `icon` is either a **built-in icon key** (a short name from the app's curated Lucide set, e.g. `swords`, `castle`, `mask`) or an **emoji character** stored verbatim (e.g. `🐉`). Values are not validated against the curated set, so a key the frontend doesn't recognise simply renders as the default icon rather than erroring. `icon_color` tints the icon. It is either a **preset token** - `red`, `orange`, `gold`, `green`, `teal`, `blue`, `purple`, `pink`, `brown`, `gray` - or a **`#rrggbb` hex literal**. Values are normalised to lowercase, and anything else is rejected with 422 (the value reaches a CSS style attribute, so the accepted shapes are deliberately narrow). Null or `""` means the icon inherits its row's text colour. Both fields round-trip through wiki export/import. On import the file is untrusted, so an `icon_color` that would fail validation is dropped rather than stored. Visibility is **not** encoded in the icon colour: it has its own indicator in the UI, so the colour is free to be a user choice. #### Categories GM-defined groupings for linked **resources**, scoped per campaign. Resources carry an optional `category_id` (null = grouped under their built-in type group: Books / Maps / Tokens / Files), set via the resource PATCH endpoint (`category_id`), using `""` to clear it. Wiki pages no longer use categories - they nest under parent pages instead (see Wiki). `kind` `note` is retired: `POST` with `kind: "note"` returns 400, and legacy note categories are converted to parent pages on startup. The resource panel's **group display order** (custom categories interleaved with the built-in type groups) is stored per campaign as `resource_group_order` (returned by the campaign GET): an ordered list of group keys - `type:book`, `type:map`, `type:token`, `type:file`, and `cat:<category_id>`. Groups absent from the list fall to the end in their default order; an empty list means the default order (categories then type groups). | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/categories` | GET | member or owner | List categories. Query: `kind?` (`resource`) | | `/api/campaigns/:id/categories` | POST | owner | Create. Body: `{name, kind, icon?, icon_color?}` (`kind` must be `resource`; see [Entry icons](#entry-icons) for `icon`/`icon_color`) | | `/api/campaigns/:id/categories/reorder` | PUT | owner | Set category sort order. Body: `{ordered_ids}` | | `/api/campaigns/:id/resource-group-order` | PUT | owner | Set the resource panel's group display order (categories + type groups). Body: `{ordered_keys}` (keys `type:book`/`type:map`/`type:token`/`type:file`/`cat:<id>`; unknown or duplicate keys are dropped) | | `/api/campaigns/:id/categories/:cat_id` | PATCH | owner | Rename / set icon. Body: `{name?, icon?, icon_color?}` (`""` clears either icon field) | | `/api/campaigns/:id/categories/:cat_id` | DELETE | owner | Delete. Query: `mode` = `uncategorize` (default; resources kept, moved out of the category) or `delete_items` (resources unlinked) | #### Wiki (notes) The campaign notebook is a set of markdown **wiki pages**. Pages link to one another with `[[Page Title]]` (or `[[Page Title|label]]`) syntax and embed campaign content inline with `[[book:ID]]`, `[[book:ID:PAGE]]`, `[[map:ID]]`, `[[token:ID]]`, `[[audio:ID]]` (an inline audio player), `[[file:ID]]` (a download card for a campaign file), or `[[image:ID]]` (an uploaded image rendered inline). `ID` is the resource's underlying id; the embed picker lists only resources already linked to the campaign (and offers an image upload). On save the body is re-parsed: unknown `[[Page Title]]` targets auto-create a stub page (inheriting the source page's visibility), embed tokens are skipped (never create stubs), and backlink rows are rebuilt. ##### Link target syntax A page-link target is a mandatory title plus two optional suffixes - `[[Page Title:id-<page_id>:#Heading]]`: | Form | Resolves to | |------|-------------| | `[[Page Title]]` | the page whose slug matches the title | | `[[Page Title:id-<page_id>]]` | that exact page, by id | | `[[Page Title:#Heading]]` | the page, scrolled to the named heading | | `[[Page Title:id-<page_id>:#Heading]]` | both | The `:id-` suffix pins a link to a page's stable identity, which makes it survive renames and lets a title that collides with another page be addressed at all (two titles differing only in case or punctuation share a slug, so the second gets a `-2` slug that a bare title can never reach). Resolution is identity-first: when `:id-` is present it is the only thing consulted, so a link whose id no longer exists renders as broken rather than silently re-pointing at whatever page holds that title now. Parsing is suffix-driven and right-to-left - a trailing `:id-…` / `:#…` is only split off when it matches that exact shape - so a title containing an ordinary colon (`[[Ancient Ruins: The Depths]]`) needs no escaping. The heading is everything after the first `:#`, so a heading that itself begins with `#` needs none either: the markdown heading `# # of coin` is linked as `[[Prices:## of coin]]`. A `:#Heading` is addressing only; the stored link row is page-to-page, and a heading never creates a stub. When several headings share text, `:#Heading` resolves to the most prominent (H1 over H2 over H3) and, among equals, the first in the page. Two behaviours keep links honest as pages change. **Renaming** a page rewrites the title portion of inbound `[[…]]` links in the bodies that point at it, preserving each link's `|label`, `:id-` and `:#Heading` (a rename that leaves the slug unchanged, e.g. recasing, rewrites nothing). **Stub auto-creation applies only to unpinned links** - a pinned `:id-` target that no longer resolves means the page was deleted, so it stays broken instead of being resurrected as an empty duplicate. Deleting a page also recomputes the link rows of the pages that referenced it. Because import assigns fresh page ids, an incoming `:id-` pin is dropped on import (the title, which *is* remapped, is the identity that survives); `:#Heading` suffixes are preserved. Pages nest: each page has an optional `parent_id` (null = top level), forming a tree of arbitrary depth (a "category" is just a page with children). Deleting a page re-parents its children to the deleted page's parent rather than removing the subtree. A page may not be its own parent or be moved under one of its own descendants (400). ##### Page permissions Every level is available to every campaign member: what each one *means* is relative to whoever authored the page, so a player keeps private notes on the same terms as the GM (issues #232, #233). | Visibility | Who can read | Who can edit | |------------|--------------|--------------| | `gm` | the author alone | the author alone | | `group` ("Public") | every campaign viewer | every campaign viewer | | `members` ("Private") | the author, plus `shared_user_ids` | the author, plus `shared_write_user_ids` | **Personal (non-GM) campaigns have no visibility levels.** They hold exactly one viewer, so every level would mean the same thing: the server stores `gm` on create regardless of what is sent, and ignores a `visibility` change on update rather than rejecting it (the UI has no control to have sent it from, so a `400` would turn a harmless no-op into an error). The frontend hides the dropdowns, badges, and row glyphs there entirely. Converting a personal campaign to a group one leaves its pages author-only, which is the safe direction - the GM opens up what they choose to. `gm` means **author only**, not "the campaign owner" - the campaign owner has no read access to a page they did not write. The UI labels it "GM only" on the GM's own pages and "Self only" on a player's, but it is one level with one rule. This is symmetric by design: a player's private note is as closed to their GM as the GM's is to them. Sharing is two lists. `shared_user_ids` grants read; `shared_write_user_ids` grants read **and** write - naming a user only in the write list is enough, since the server adds the read grant. Both lists are returned only to the page's author; to anyone else they come back empty, so a reader cannot enumerate who else holds access. Three rights are narrower than editing: - **Deleting** is the author's alone. A public page is editable by everyone but destroyable only by the person who wrote it; anyone else who wants it gone from their view [hides it](#hidden-pages) instead. - **Reclassifying** (changing `visibility`, `shared_user_ids`, or `shared_write_user_ids`) is the author's alone, so a contributor to a public page cannot take it private and remove it from everyone else. `403` otherwise. - **Nesting** a page under a parent requires *write* access to that parent, so children cannot be grafted onto someone else's page. A parent the caller cannot write is `403`; one they cannot even read is reported as `400 Invalid parent page`, so the response does not confirm that a hidden page exists. ##### Hidden pages Any user may hide any page they can see, including ones they can neither edit nor delete. This is per-user decluttering, not a permission: it changes nothing for anybody else. Hiding a parent hides its whole subtree, resolved at read time from `parent_id` rather than stored per descendant - so moving a page out of a hidden subtree un-hides it, and a page created under a hidden parent starts hidden. Hidden pages are excluded from the list and from `search`; `GET /wiki?include_hidden=true` brings them back, each flagged with `is_hidden`. Unlike other wiki writes, hiding is permitted on an **archived** campaign, since it writes nothing to the campaign's content. **Personal campaigns have no hidden pages.** The feature exists so one person can put *someone else's* notes out of their own way, and there is nobody else - deleting already covers your own. `POST .../hide` returns `409` there, and every read path (list, detail, `search`) reports nothing as hidden regardless of what rows exist. That last part matters for a campaign that was a group one when a row was written, or one predating the guard: honouring a stale row would drop the page from the list with no UI able to bring it back. `DELETE .../hide` stays available for exactly that reason - clearing the state is always safe. ##### GM secrets Within a page body, text wrapped in `||double pipes||` is a **GM-only secret** - finer-grained than page visibility, it hides a span inside an otherwise shared page. The campaign owner always receives the raw `||...||`. For every player the secret (markers and enclosed text, which may span multiple lines) is **fully stripped** from the page `body` server-side, leaving no trace - no secret text and no placeholder - so a player never learns a secret exists or where, whether in the rendered page, the raw editor body, or a `search` snippet/match. Personal (non-GM) campaigns are never stripped, since only the owner can view them. **Secrets are keyed on campaign ownership, not authorship** - deliberately unlike every other rule on this page. A secret's purpose is to hide text from *players*, so it belongs to the GM wherever the page came from: the GM may annotate a player-authored session log with hidden notes that page's own author cannot see. The visibility rework made read/write/delete author-relative; this mechanism did not follow. A player typing `||` is not the mirror of that. Their pipes are **escaped to literal text** on save - each `|` in a run of two or more is stored as `\|` - rather than honoured or rejected. Honouring them would create a secret the player themselves could not see: they would write a sentence, save, and watch it vanish on the next read. Escaping keeps what they typed on screen (markdown renders `\|` as a literal `|`) and stops it ever being mistaken for a GM secret. Single pipes are untouched, so markdown tables round-trip unchanged, and the escape is idempotent, so re-saving never stacks backslashes. Applied on both create and update, to every non-owner submission; wiki *import* is owner-only, so pipes arriving that way are the GM's and are left alone. Because a player edits this stripped body, a player saving an edit would otherwise erase the secrets - which matters far more now that a `group` page is editable by the whole party, so any player may be the one saving over a GM's page. On such a save the stored secrets are **re-woven back by position** server-side: the stripped text the player was last shown is diffed against their submission, and each secret is re-inserted at the point its surrounding text maps to. A secret therefore stays exactly where the GM placed it even when the player edits the text above and/or below it (it does not drift past later paragraphs). If the text on both sides of a secret was rewritten past recognition, that secret is appended at the end of the body - preserved, never lost. The GM submits the raw body (secrets and all), which is stored verbatim. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/wiki` | GET | member or owner | List pages the caller can see (`id, title, slug, visibility, page_type, session_date, parent_id, icon, icon_color, sort_order, updated_at, can_edit, can_delete, is_mine, is_hidden`), ordered by `sort_order`. Build the page tree client-side from `parent_id`. Query: `mine=true` restricts to pages the caller authored; `include_hidden=true` also returns the pages they have [hidden](#hidden-pages) (omitted by default) | | `/api/campaigns/:id/wiki` | POST | member or owner | Create a page. Body: `{title?, body?, visibility?, page_type?, session_date?, shared_user_ids?, shared_write_user_ids?, parent_id?, icon?, icon_color?}` (`parent_id` nests the page and requires write access to that parent; see [Page permissions](#page-permissions) and [Entry icons](#entry-icons)). Any visibility may be created by any member | | `/api/campaigns/:id/wiki/search` | GET | member or owner | Search visible pages by title/body. Query: `q` | | `/api/campaigns/:id/wiki/titles` | GET | member or owner | `{id, title, slug, ambiguous, parent_title, headings[]}` list for `[[link]]` autocomplete. `ambiguous` is true when another visible page normalizes to the same slug (the editor then emits `:id-`, and labels the suggestion `Title (parent_title)` to tell the collisions apart); `parent_title` is the immediate parent's title, or null at top level **or when that parent isn't visible to the caller**; `headings` is the page's ATX headings as `{text, level}` in document order, for `:#Heading` completions. Headings inside `\|\|GM secrets\|\|` are omitted for non-owners | | `/api/campaigns/:id/wiki/reorder` | PUT | member or owner | Drag-and-drop order. Body: `{ordered_ids}`. **Relative, not absolute**: `sort_order` is global but a caller only sees a subset, so the submitted list is applied as an ordering *of the slots those pages already occupy* - the pages named are redealt into their own existing positions, and every other page (invisible to the caller, or visible but not writable by them) keeps the `sort_order` it had. A caller's drag therefore never renumbers pages they cannot see. Ids the caller cannot write are skipped and hold their slot; unknown and duplicate ids are ignored rather than rejected | | `/api/campaigns/:id/wiki/export` | GET | owner or member | Export the wiki - a member can take their own copy of a campaign with them (e.g. when leaving, or moving to another platform), and this **works on an archived campaign** since it only reads. Everyone receives exactly what they can see in the app: pages failing the `can_view_page` check are omitted, and `||GM secrets||` are stripped from a player's bodies. The page filter now applies to the campaign owner too - since `gm` visibility means author-only, a GM is no more entitled to export a player's self-only note than to read it - but secrets stay GM-only regardless of who authored the page holding them. Query: `format` = `md` (a `.zip` of one Markdown file per page, with YAML frontmatter incl. `parent` slug - Obsidian-friendly), `mdfile` (a single `.md` file with every page concatenated in sidebar order, page titles as headings nested by tree depth and each page's own headings shifted below its title - for reading or printing, not re-import), or `json` (a Grimoire JSON bundle: `{grimoire_wiki_version, campaign, pages[]}`, each page carrying its `parent` slug). Returns a file download | | `/api/campaigns/:id/wiki/import` | POST | owner | Import pages from a multipart `file`, **or** from a whole picked folder as repeated `files` parts plus one `paths` entry per file (the browser's `webkitRelativePath`), paired by position - sending both `file` and `files` is `400`. A folder pick takes only markdown (dot-directories such as `.obsidian/` skipped) and the whole set imports in one request so nesting and links resolve together; the 25 MB cap applies to the pick as a whole, with at most 2000 files. A `.zip` is held to the same limits once unpacked - 25 MB of decompressed content and 2000 files - and is refused with `413` past either. Accepts a single `.md`/`.markdown`/`.txt`, a Grimoire `.json` bundle, a LegendKeeper export (`.json`/`.lk` - a per-page export or a current `{version, resources[]}` bundle with ProseMirror bodies), or a `.zip` (Markdown vault, Grimoire bundle, or LegendKeeper directory export). LegendKeeper HTML and ProseMirror bodies are converted to Markdown (lossy for LegendKeeper-only blocks, which are dropped); page nesting (`parent`/`parentId`) is preserved. In a zip, folders become nesting too - `Places/Cities/Waterdeep.md` nests three deep, a page being created per folder unless a folder note (`<Folder>/<Folder>.md`) or `index.md`/`_index.md` already stands for it; a record's own `parent` outranks its folder, so the Markdown zip export round-trips. Import is non-destructive: every record becomes a new page (slugs de-duplicated), existing pages are never overwritten, and internal links are remapped. Returns `{imported, format, pages[]}` | | `/api/campaigns/:id/wiki/templates` | GET | owner | The campaign's note templates. Returns `{templates[], campaign_system, downloads_enabled}`; each template carries `{id, name, system, category, description, source_id, source_url, source_version, created_at}` (no `body` - use the detail read). `campaign_system` is the linked game-system name, falling back to free-text `system_name`, else `""`. `downloads_enabled` is false when `DISABLE_EXTERNAL_ADD_ON_INSTALL` is set. Also returns `categories` (the suggested set plus any the campaign already uses, for the editor's dropdown) and `authored_system` (the marker stored on hand-written templates) | | `/api/campaigns/:id/wiki/templates` | POST | owner | Write a new template. Body: `{name, category?, description?, body?, defaults?}`. `name` is required and non-blank. There is no `system`: a hand-written template is stored with the `__authored__` marker (reported as `authored_system` on the list endpoint). `defaults` is `{title?, icon?, icon_color?, visibility?, page_type?}` and is stored as a YAML frontmatter block on the body, so authored, uploaded, and downloaded templates all share one on-disk shape. Returns the template incl. `body` and `defaults` | | `/api/campaigns/:id/wiki/templates/browse` | GET | owner | The community catalogue as a folder tree. Returns `{folders[], downloaded_ids[], campaign_system, index_url, is_custom_url, generated}`; each folder is `{path, name, templates[]}` with the generic folder pinned first and the rest alphabetical. Each template is `{id, name, version, system, category, description, author, author_url}`, where `author` is the "by <author>" credit and `author_url` its server-derived GitHub profile link (both `""` when absent). Query `refresh=true` bypasses the 1-hour cache. The catalogue comes from the server's add-on sources (`index_url` on `PATCH /api/addons/settings`), falling back to the built-in template catalogue; `is_custom_url` says whether it is anything else. `403` when downloading is disabled, `502` when the catalogue is unreachable | | `/api/campaigns/:id/wiki/templates/upload` | POST | owner | Add a template from a multipart `file` (max 512 KB), either a `.md`/`.markdown`/`.txt` or a `.zip` in the export layout (`<id>/<id>.yml` + `<id>/<id>.md`). For markdown, the frontmatter `title` names it, falling back to the filename; for a zip, the manifest supplies `name`/`category`/`description`, falling back to the body's frontmatter when absent or malformed. Zips are detected by magic bytes as well as extension, `__MACOSX/` and dotfiles are skipped, and a member declaring more than the size cap is refused with `413` before decompression. A zip with no markdown in it, or a corrupt archive, is `400`. **Works even when downloading is disabled** - the hand-copy path for locked-down servers | | `/api/campaigns/:id/wiki/templates/download/:template_id` | POST | owner | Copy a community template into the campaign, verifying the body against the catalogue's `body_sha256`. Recorded with `source_id`/`source_url`/`source_version`. Downloading the same template twice deliberately makes a second copy. `403` when disabled, `404` if unknown, `502` if the fetch fails | | `/api/campaigns/:id/wiki/templates/:template_id` | GET | owner | One template. `body` is the Markdown **without** its frontmatter block; the block's contents come back separately as `defaults` `{title, icon, icon_color, visibility, page_type}`, so the editor can show form controls instead of raw YAML. A body with no frontmatter reports empty defaults | | `/api/campaigns/:id/wiki/templates/:template_id` | PATCH | owner | Edit a template; every field optional. A blank `name` is rejected with `400`. `body` and `defaults` are two halves of one stored string: sending either rebuilds it from the current value of the other, so a body-only edit keeps the page defaults and vice versa. Any frontmatter still on an incoming `body` is stripped before the block is rebuilt, so a client that round-trips the whole document cannot stack two blocks | | `/api/campaigns/:id/wiki/templates/:template_id` | DELETE | owner | Delete the template. Pages already created from it are unaffected | | `/api/campaigns/:id/wiki/templates/:template_id/export` | GET | owner | Download the template as a `.zip` holding `<id>/<id>.yml` + `<id>/<id>.md` - the community repo's folder layout, ready to contribute back. Uses `source_id` as the folder name when the template was downloaded, else a slug of its name | | `/api/campaigns/:id/wiki/templates/:template_id/use` | POST | owner | Create a wiki page from the template server-side. The Markdown body (with its frontmatter) is run through the same parser as file import. Non-destructive: always a new page with a de-duplicated slug. Returns `{imported, template_id, pages[]}`. **The web UI does not use this** - it reads the template and opens an unsaved editor instead, so picking the wrong template costs a cancel rather than a delete. Kept for API clients that do want a page created in one call | | `/api/campaigns/:id/wiki/:page_id` | GET | per visibility | Page detail incl. `body`, `backlinks`, `icon`, `icon_color`, `can_edit`, `can_delete`, `is_mine`, `is_hidden`. `shared_user_ids` / `shared_write_user_ids` are populated only for the page's author and empty for everyone else | | `/api/campaigns/:id/wiki/:page_id` | PATCH | page author, or anyone with write access | Update fields (each optional; `icon: ""` / `icon_color: ""` clear those fields). Body and metadata follow write access, but `visibility` / `shared_user_ids` / `shared_write_user_ids` are author-only (`403` otherwise), and either share list alone is a full replacement of the pair - the one you omit is preserved as stored. See [Page permissions](#page-permissions) | | `/api/campaigns/:id/wiki/:page_id` | DELETE | page author | Delete the page and its link rows. Author-only: neither the campaign owner nor a member with write access may delete a page they did not author (`403`) | | `/api/campaigns/:id/wiki/:page_id/hide` | POST | per visibility | [Hide](#hidden-pages) the page from the caller's own view. Available on any page they can see, including ones they cannot edit or delete, and permitted on an archived campaign. `409` in a personal campaign, which has no hidden pages | | `/api/campaigns/:id/wiki/:page_id/hide` | DELETE | any member | Un-hide a page the caller had hidden. Clears only their own row; a page hidden because an ancestor is hidden has no row of its own, so this is a no-op for it. Allowed in a personal campaign even though hiding is not - clearing state is always safe, and refusing would strand a row written before that guard existed | #### Sessions (legacy) Superseded by the wiki. On startup, any non-empty legacy session notes are rolled into wiki pages (GM internal → a `gm` page, GM external → a `group` page, each player note → a `group` page) and the legacy session rows are removed; empty sessions are simply purged. These endpoints remain for backward compatibility. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/sessions` | GET | member or owner | List sessions sorted by date | | `/api/campaigns/:id/sessions` | POST | member or owner | Create session. Body: `{session_date, title?}` (date: `YYYY-MM-DD`) | | `/api/campaigns/:id/sessions/:sid` | GET | member or owner | Session detail. GM sees `internal_content`; members see only `external_content`. | | `/api/campaigns/:id/sessions/:sid` | PATCH | owner | Update `title` | | `/api/campaigns/:id/sessions/:sid` | DELETE | owner | Delete session and all notes | | `/api/campaigns/:id/sessions/:sid/notes/player` | PUT | member or owner | Save own player note. Body: `{content}` | | `/api/campaigns/:id/sessions/:sid/notes/gm` | PUT | owner | Save GM notes. Body: `{internal_content?, external_content?}` | #### Schedule | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/schedule` | GET | member or owner | Returns `{definition, enabled, next_sessions}`. When `enabled` is false the definition is preserved but `next_sessions` is empty. | | `/api/campaigns/:id/schedule` | PUT | owner | Create or update schedule (GM campaigns only). Body accepts `enabled` (default true); setting it false keeps the definition but deactivates the schedule (no next sessions, no availability chart). | | `/api/campaigns/:id/schedule` | DELETE | owner | Remove schedule | **Schedule body:** ```json { "frequency": "weekly", "days": [5], "time_local": "18:00", "timezone": "America/Los_Angeles", "biweekly_reference": "2026-01-03", "monthly_week": null, "custom_dates": null } ``` | Field | Description | |-------|-------------| | `frequency` | `weekly`, `biweekly`, `monthly`, or `custom` | | `days` | Local weekday indices - `0` = Monday … `6` = Sunday | | `time_local` | Session time (`HH:MM`) in the zone named by `timezone` | | `timezone` | IANA zone the days and time are expressed in (e.g. `America/Los_Angeles`) | | `time_utc` | **Deprecated.** A UTC clock; still accepted from older clients and converted to `time_local` on write | | `biweekly_reference` | Anchor date for biweekly cadence (`YYYY-MM-DD`) | | `monthly_week` | Week of month: `1`–`4`, or `-1` for last | | `custom_dates` | Array of explicit dates (`YYYY-MM-DD`) for `custom` frequency | #### Availability | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/availability` | GET | member or owner | Availability chart for next 10 scheduled sessions | | `/api/campaigns/:id/availability/:date` | PUT | member or owner | Set availability. Body: `{status, is_cancelled?, user_id?}` - `status` is `available`/`tentative`/`unavailable`. `user_id` (a user id string) and `is_cancelled` are GM/admin only: `user_id` sets that member's availability instead of the caller's | | `/api/campaigns/:id/availability/:date/cancel` | PUT | owner | Toggle session cancellation for a date | Availability statuses: `available`, `tentative`, `unavailable` #### Calendar export and subscription Campaign schedules can be exported as iCalendar (`text/calendar`) - either a one-off download or a live feed a calendar app subscribes to and re-polls. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/campaigns/:id/calendar.ics` | GET | member or owner | Download the campaign's upcoming sessions as an `.ics` file | | `/api/campaigns/calendar/subscription` | GET | any user | Own subscription state. Optional `?campaign_id=` adds `campaign_feed_url` | | `/api/campaigns/calendar/subscription` | POST | any user | Mint or rotate the feed token; the previous URL stops working immediately | | `/api/campaigns/calendar/subscription` | DELETE | any user | Revoke the feed token | | `/api/campaigns/calendar/:token/:id.ics` | GET, HEAD | feed token | Live feed for one campaign | | `/api/campaigns/calendar/:token/all.ics` | GET, HEAD | feed token | Live feed merging every campaign the token's user belongs to | **Feed-token auth.** Calendar apps cannot send an `Authorization` header, so the two feed endpoints carry a per-user token in the path and sit *outside* the JWT-protected `/api` dependency. The token is a dedicated `users.calendar_token`, not the JWT and not `opds_token`: it grants read access to schedule data alone and rotates independently of login sessions and OPDS. An unknown, rotated, or revoked token returns **404** rather than 401, so probing cannot distinguish a revoked feed from one that never existed. The token identifies the user; membership still authorises. A feed for a campaign the user has left - or that has been archived - returns 404, and the aggregate feed simply omits it. **Session times are published as local wall clocks with an explicit `TZID`.** A schedule's `days` are *local* weekday indices and its stored time is a *local* clock, so the definition also carries an optional IANA `timezone` (captured from the browser on save, validated on write). When it is present the feed emits `DTSTART;TZID=<zone>:<local time>` and ships a matching `VTIMEZONE`, so the session keeps its weekday in every reader's rendering and stays correct across DST. > Publishing a UTC instant instead is what put evening games on the wrong day. Collapsing "Sunday 19:30 America/Los_Angeles" to `20260816T023000Z` is arithmetically correct but *already Monday in UTC*, and every client re-renders that in the viewer's own zone - showing Saturday night to the Pacific players whose game it is. The instant was right; the format threw the weekday away. > **The storage model changed with it.** Schedules previously stored a *local* weekday beside a *UTC* clock, and the browser's conversion dropped the day the clock rolled into: 19:30 Pacific was saved as `02:30` while `days` still said Sunday, so the pair described Saturday evening and the feed published it faithfully. Both halves are now local (`time_local` + local `days`), which removes the rollover by construction. A startup migration (`_migrate_schedule_times_to_local`) converts existing rows back through their recorded zone, repairing the clock and leaving `days` untouched - the weekday was always the half the UI displayed correctly. It is marker-guarded (`time_model: "local"`) and therefore idempotent. Rows with no `timezone` have nothing to convert against and keep their clock; re-saving the schedule records a zone. A schedule with no zone publishes a *floating* local time (`DTSTART:<local>`, no `TZID`, no trailing `Z`) - RFC 5545 §3.3.5 reads that in the viewer's own zone, which is the best available interpretation of a bare `19:30` and, unlike the old UTC form, cannot shift the weekday. `VTIMEZONE` components are derived from the events themselves - every zone referenced by a `TZID` gets one emitted ahead of the events, with an explicit `STANDARD`/`DAYLIGHT` subcomponent per transition in the published window rather than an `RRULE`. Events carry `SEQUENCE:1`, bumped from `0` when the format changed so subscribers holding the old UTC events accept the correction. **Feeds answer `HEAD` as well as `GET`.** Google Calendar's *From URL* flow probes a subscription URL with `HEAD` before accepting it, and rejects the feed outright if that probe is not a success - importing nothing and surfacing no error. Both feed routes therefore register `HEAD`, which returns the real status and `Content-Type` with an empty body. **Feeds are served `inline`; only the download is an `attachment`.** Google Calendar's *From URL* fetcher rejects an ICS feed sent with `Content-Disposition: attachment` - it reads the header as "save this file" rather than "poll this calendar". Apple Calendar and Outlook ignore the header on a URL they were explicitly asked to subscribe to, which is why an `attachment` feed appears to work everywhere *except* Google. Both feed routes therefore send `inline`; the one-off `/:id/calendar.ics` download keeps `attachment`, where prompting a save with a filename is the point. **Subscription URLs require `BASE_URL`.** The server has to know its own public origin to build a URL a calendar app can reach, so `GET`/`POST /calendar/subscription` report `base_url_configured: false` and withhold every URL while `BASE_URL` is still the `http://localhost:9481` default; `POST` returns 400. The one-off `.ics` download is unaffected. Responses also include `webcal_url` - the same URL under the `webcal://` scheme, which makes desktop calendar apps subscribe rather than download a static copy. > **Google Calendar fetches server-side, so the feed must be publicly reachable.** Unlike Apple Calendar and Outlook, which poll from the user's own machine, Google fetches the URL from its infrastructure - a LAN address, a `.local` hostname, a Tailscale/VPN-only origin, or anything behind an authenticating reverse proxy will fail no matter what the feed returns. Google also requires the `https://` form: it does not understand `webcal://`, so paste `feed_url`, not `webcal_url`. Plain `http://` is likewise unreliable. **Feed contents.** Up to 26 upcoming sessions per campaign. Each `VEVENT` uses a stable UID (`grimoire-session-<campaign_id>-<YYYY-MM-DD>@grimoire`), so rescheduling updates the existing event in place instead of duplicating it, and a cancelled session is published as `STATUS:CANCELLED` rather than disappearing. Sessions carry `DTSTART`/`DTEND` in UTC when the schedule sets a time, and are all-day events when it does not. Feeds are **personalised to the token's owner**: each event's `SUMMARY` and `DESCRIPTION` reflect that user's own availability, and `URL` deep-links to the campaign's schedule tab. > **RSVP does not travel back over a subscription.** A subscribed ICS feed is fetched by HTTP `GET`; iCalendar defines no write path back, so Accept/Tentative/Decline is inert in Google Calendar, Apple Calendar, and Outlook for subscribed events. Genuine RSVP requires iMIP (emailed `METHOD:REQUEST` invitations with replies parsed from a mailbox) or a CalDAV server - neither of which Grimoire implements. The deep link in every event is the round trip instead. ### Settings *(admin only)* | Endpoint | Method | Description | |----------|--------|-------------| | `/api/settings` | GET | Get all application settings | | `/api/settings` | PATCH | Update application settings | | `/api/settings/ui` | GET | UI visibility flags (any authenticated user) | | `/api/settings/oidc-button-icon` | POST | Upload the OIDC button icon. Multipart `file`: PNG, JPEG, WebP, GIF or SVG, max 1 MB (413 above that, 400 for anything that isn't a decodable image). The image is re-encoded - SVGs are rasterized - to a PNG at most 192 px on its longest side, so the uploaded bytes are never served. 400 when `OIDC_BUTTON_ICON` pins it. Returns the full settings. | | `/api/settings/oidc-button-icon` | DELETE | Remove the uploaded icon. 400 when `OIDC_BUTTON_ICON` pins it. Returns the full settings. | The settings response no longer carries a `stats_api_key`, and `PATCH` ignores one: API keys moved to [`/api/api-keys`](#api-keys-1), where the key itself is never readable back. `GET /api/settings` and `PATCH /api/settings` also carry the library watcher (see [Watching for changes](file-management.md#watching-for-changes)): `library_watch_enabled` (bool, default `true`) and `library_watch_mode` (`auto`, `events`, or `poll`) are settable, and answer `400` while `LIBRARY_WATCH` or `LIBRARY_WATCH_MODE` pins them; `library_watch_env_locked` and `library_watch_mode_env_locked` say which are pinned. `library_watch_state` is what the watcher last reported - `{"state": "watching" | "polling" | "off" | "unavailable", "detail": "<why it is unavailable>"}` - and `library_watch_poll_interval` is the poll period in seconds. A change applies within a few seconds, with no restart. `GET /api/settings/ui` also returns `library_writable`: whether the library root can be written to at all. It is not a stored setting - it is probed at request time - and it gates the move / rename / delete affordances in views outside the file manager, which have no folder listing of their own to ask. Clients should treat a missing value as `false` so the destructive actions stay hidden rather than appearing and failing. **Configurable settings:** | Key | Type | Description | |-----|------|-------------| | `rescan_schedule_enabled` | bool | Enable automatic library rescans | | `rescan_schedule_interval` | string | `hourly`, `daily`, or `weekly` | | `rescan_schedule_hour` | int | UTC hour (0–23) for daily/weekly rescans | | `rescan_schedule_minute` | int | UTC minute (0–59) | | `rescan_schedule_weekday` | int | Weekday (0–6) for weekly rescans | | `hide_maps` | bool | Hide the maps section in the UI | | `hide_tokens` | bool | Hide the tokens section in the UI | | `hide_audio` | bool | Hide the audio section in the UI | | `hide_campaigns` | bool | Hide the campaigns section in the UI | | `restricted_categories` | object | `{category_slug: "gm"\|"admin"}` restricting whole categories library-wide (issue #258). The category-level tier of the access cascade; a book or system still overrides it in either direction. `core` and `character-sheet` are rejected with 400 - everyone at the table needs those. An entry set to `""` is dropped rather than stored, since "open" is the absence of an entry. See [Access levels](#access-levels-issue-258). | | `show_stat_systems` | bool | Show/hide game system count in sidebar | | `show_stat_books` | bool | Show/hide book count in sidebar | | `show_stat_pages` | bool | Show/hide page count in sidebar | | `show_stat_maps` | bool | Show/hide map count in sidebar | | `show_stat_tokens` | bool | Show/hide token count in sidebar | | `show_stat_audio` | bool | Show/hide audio track count in sidebar (default off) | | `show_stat_size` | bool | Show/hide library size in sidebar | | `show_stat_version` | bool | Show/hide version in sidebar | | `password_auth_enabled` | bool | Allow password sign-in. Cannot be patched if `ALLOW_PASSWORD_AUTHENTICATION` is set in the environment (returns 400). The `password_auth_env_locked` field on the GET response indicates whether the env override is active. | | `guest_access_enabled` | bool | Allow GMs/admins to create guest invite codes. Cannot be patched if `GUEST_ACCESS_ENABLED` is set in the environment (returns 400). The `guest_access_env_locked` field on the GET response indicates whether the env override is active. | | `share_links_role_gm` | bool | Whether GMs may create [share links](#share-links-1) without a per-user override. Default `true`. Admins always may. | | `share_links_role_player` | bool | Whether players may create share links without a per-user override. Default `false`. | | `disable_folder_category_inference` | bool | When `true`, the indexer does not infer a book's category from its folder names; books fall back to `uncategorized`. Cannot be patched if `DISABLE_FOLDER_CATEGORY_INFERENCE` is set in the environment (returns 400). The `disable_folder_category_inference_env_locked` field on the GET response indicates whether the env override is active. A per-system `.no-auto-category` marker file disables inference for just that system. | | `custom_login_message_enabled` | bool | Show a custom message above the sign-in form | | `custom_login_message` | string | HTML for the login message. Sanitized server-side: only `<b>`, `<strong>`, `<i>`, `<em>`, `<s>`, `<strike>`, `<del>`, `<u>`, `<p>`, `<br>`, `<ul>`, `<ol>`, `<li>`, and `<a href>` (http/https/mailto/relative) are allowed; everything else is dropped. | | `oidc_enabled` | bool | Master toggle for OIDC sign-in. Has no effect until issuer / client id / client secret are also set. | | `oidc_issuer_url` | string | Base URL of the IdP (e.g. `https://idp.example.com/realms/main`). | | `oidc_authorization_endpoint` | string | Discovered or manual authorization endpoint URL. | | `oidc_token_endpoint` | string | Discovered or manual token endpoint URL. | | `oidc_userinfo_endpoint` | string | Discovered or manual userinfo endpoint URL. | | `oidc_jwks_uri` | string | Discovered or manual JWKS URL - required to validate the ID token signature. | | `oidc_end_session_endpoint` | string | Optional RP-initiated logout endpoint. | | `oidc_client_id` | string | Client ID issued by the IdP. | | `oidc_client_secret` | string | **Write-only.** Setting a non-empty string saves it, encrypted under `SECRET_KEY` so a database backup doesn't carry it (if `SECRET_KEY` later changes, the stored secret reads as unset and must be entered again). Setting `OIDC_CLIENT_SECRET` in the environment instead is recommended. Empty string is a no-op (so form re-submits don't clobber). The literal `"__CLEAR__"` wipes the stored secret. GET responses never return the value - instead, `oidc_client_secret_set: bool` and `oidc_client_secret_length: int` are returned. | | `oidc_signing_alg` | string | One of `RS256`/`RS384`/`RS512`/`ES256`/`ES384`/`ES512`/`PS256`/`PS384`/`PS512`. Default `RS256`. ID tokens are verified against the provider's JWKS with only this algorithm accepted, so a token in any other algorithm (`none` included) is refused. `HS256` is no longer accepted (400); a value saved before still reads back, and makes every sign-in fail with an "is not supported" error until it is changed. | | `oidc_button_text` | string | Label for the SSO button on the login page. | | `oidc_button_bg_color` | string | Button background as a CSS hex color (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), stored lowercase. `""` = theme default. Anything else returns 400. | | `oidc_button_text_color` | string | Button text color, same format. | | `oidc_button_border_color` | string | Button border color, same format. | | `oidc_button_radius` | string | Corner radius in px, an integer `0`-`40`. `""` = default. | | `oidc_groups_claim` | string | Optional. Name of the claim containing group memberships. When set, roles are assigned from the groups named by `oidc_admin_group`, `oidc_gm_group` and `oidc_player_group` (highest wins); users in none of them are denied. | | `oidc_admin_group` | string | The group that grants the admin role. Default `admin`; `""` resets it. Matched exactly, ignoring case; a Keycloak top-level path (`/admin`) also matches, but a subgroup path or a name that merely ends in the role (`jellyfin-admin`) does not. | | `oidc_gm_group` | string | The group that grants the GM role, matched the same way. Default `gm`. | | `oidc_player_group` | string | The group that grants the player role, matched the same way. Default `player`. | | `oidc_permissions_claim` | string | Optional. Name of the claim containing a permissions object (e.g. `{viewNSFW: bool}`). When set, the claim must be present in every login or access is denied. | | `oidc_match_by` | string | One of `none`, `email`, `username`. How to link an existing local account to an OIDC subject on first login. Only unlinked, non-guest accounts are considered, and `username` never links an admin account. | | `oidc_auto_launch` | bool | When true, `/login` immediately redirects to the IdP. Suppress with `?autoLaunch=0`. | | `oidc_auto_register` | bool | Auto-create local accounts on first OIDC login. | GET responses also include a sibling `<key>_env_locked: bool` for each individual OIDC setting, indicating whether the value is pinned by an environment variable. Patching a locked field returns 400. The fixed callback URL is exposed as `oidc_redirect_uri`, and the current button icon (set through `/api/settings/oidc-button-icon`, or pinned by `OIDC_BUTTON_ICON`) as `oidc_button_icon_url` (`""` when there is none). ### API keys Manage [API keys](#api-keys). These need a **session** - an API key can never reach them - and refuse guests. Everyone manages only their own keys; an admin can also list and revoke anyone's - including keys whose owner has since lost access. A user without access gets `403`, and with `API_KEYS_ENABLED=false` every endpoint here does. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/api-keys` | GET | any | Your keys, newest first: `[{id, user_id, username, name, prefix, permissions, created_at, last_used_at, expires_at, expired}]`. Never includes the key itself. `?all=true` (admin only) lists every user's keys. | | `/api/api-keys/permissions` | GET | any | The permissions your role can grant a key, group by group: `[{id, tags, description, group, levels}]`. `group` is the key editor's section - `library`, `media`, `campaigns` or `admin`. | | `/api/api-keys` | POST | any | Create a key acting as you. Body: `{name, permissions?, expires_at?}` where `permissions` is `{permission \| "*": "none"\|"read"\|"write"}` (omitted = No access) and `expires_at` is an ISO timestamp in the future, or null/omitted for never. Returns `201` with `{api_key, key}` - `key` is the full key, returned **only here and by regenerate**. | | `/api/api-keys/{id}` | PATCH | owner | Rename a key or change its permissions or expiry. Body: any of `{name, permissions, expires_at}`; send `expires_at: null` to make it never expire, or omit it to leave it unchanged. | | `/api/api-keys/{id}/regenerate` | POST | owner | Issue a new key, keeping the name and permissions. The old key stops working immediately. Returns `{api_key, key}`. | | `/api/api-keys/{id}` | DELETE | owner or admin | Revoke (delete) a key. `204`. | Someone else's key is a `404` for everything but an admin's delete - nobody, admins included, can edit or mint a secret for a key that isn't theirs. `400` for an unknown permission, a level your role doesn't offer, a blank name, or an expiry in the past. ### Grimoire Codex DB (issue #35) [Grimoire Codex DB](codex.md) is the built-in community metadata source. It exists only on a server with `GRIMOIRE_CODEX_DB_API_TOKEN` set. Without it the source is not listed, `/api/codex/status` reports `enabled: false`, and every other `/api/codex/*` endpoint answers `404`. Looking a record up uses the metadata endpoints above with `source_id: "grimoire-codex"`: - **Book search** goes to Grimoire Codex DB's `/match` with everything known about the book (title, file name, ISBN, product code, authors, publisher, system name, year, page count, and the file's SHA-256 when *Send file hashes* is on). A non-blank `query` that differs from the title sends only the query and the system name. Each result's `label` ends with why it matched; `score` is Grimoire Codex DB's confidence (0-1). - **System search** is a name search. - **`paste`** accepts a Grimoire Codex DB link (`…/books/{id}`, `…/systems/{id}`) or a bare id. - **Fetch** diffs the Grimoire Codex DB record's fields plus `codex_id`. Applying `codex_id` through `PATCH /api/books/:id` or `PATCH /api/systems/:id` links the record. Category, page count and file size are never offered. `identity` must be a Grimoire Codex DB record id (4-40 letters, digits, `_` or `-`), the same rule as a `codex_id` PATCH; anything else is `400` and nothing is sent to Grimoire Codex DB. Grimoire Codex DB failures map to: `400` when Grimoire Codex DB is not configured, turned off, or rejects the token, `404` when the record is not in Grimoire Codex DB, Grimoire Codex DB's own `4xx` (e.g. `422` with its validation message) when it refuses a submission, and `502` when it cannot be reached or fails. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/codex/status` | GET | not guest | `{enabled, url}` - whether Grimoire Codex DB is on (`GRIMOIRE_CODEX_DB_API_TOKEN` is set), and where it is. Always answers, so the UI knows whether to show anything | | `/api/codex/settings` | GET | admin | `{url, send_hashes, locked}`. The token is never returned. `locked` lists settings pinned by a `GRIMOIRE_CODEX_DB_*` environment variable | | `/api/codex/settings` | PUT | admin | Body `{url?, send_hashes?}`. The token can only be set through `GRIMOIRE_CODEX_DB_API_TOKEN`. `400` for an invalid URL or a pinned setting. Returns the settings | | `/api/codex/test` | POST | admin | Reaches Grimoire Codex DB with the token. Returns `{ok, url, account}`, where `account` is `{name, role}` for the token's Grimoire Codex DB account. `400` if Grimoire Codex DB rejects the token | | `/api/codex/books/:id/compare` | GET | gm/admin | What sending the book would change. Returns `{linked, codex_id, codex_url, revision, fields, cover, attributions, attribution_sources, system}`. `fields` is `[{field, local, remote, status}]` for every sendable field either side has, `status` being `only_local` (Grimoire Codex DB has nothing), `differs`, `same` or `only_remote` (this library has nothing); unlinked, every field is `only_local`. `cover` is `{local, remote_url, status}` (`status` as for fields, or `none`), compared by content hash. `attributions` are the credits already on the record; `attribution_sources` the enabled scrapers for this target as `{id, name, url}`, `url` being the book's own link on that scraper's site, or `""`. `system` (unlinked books only) is `{id, name, codex_id, candidates}`, `candidates` being `[{codex_id, name, label, url}]` from a Grimoire Codex DB name search while the system is not linked. `404` if the linked record is gone | | `/api/codex/systems/:id/compare` | GET | gm/admin | The same for a game system (`system` is always `null`) | | `/api/codex/books/:id/submit` | POST | gm/admin | Send a book. Body `{fields?, note?, cover?, sources?, attribution?, system?, base_revision?}`. Unlinked: creates a Grimoire Codex DB record under its game system's Grimoire Codex DB record. If that system is not linked, `system` is `{codex_id}` (an existing Grimoire Codex DB system, linked locally once the book is sent) or `{create: true}` (send the system first, with its cover and the same `note` and `sources`); without either it is a `400`. Linked: sends `fields` as a correction (default: every sendable field); `fields: []` is allowed with a `cover` or `attribution`. `cover: true` uploads the record's cover (`400` if it has none). `sources` are up to 10 http(s) links for reviewers. `attribution` is `{source, url, fields}`, `fields` from `description`/`tags`, crediting those fields to another catalogue. `base_revision` is the `revision` from `compare`. Returns `{status, codex_id, linked, edit_url, system}`, `status` being `applied`, `pending`, or `waiting_for_system` when a new system waits for review and the book was not sent; `system` is the system's own result when it was sent first | | `/api/codex/systems/:id/submit` | POST | gm/admin | The same for a game system (no `system`) | | `/api/codex/books/:id/link` | DELETE | gm/admin | Clear the book's `codex_id`. Nothing changes in Grimoire Codex DB | | `/api/codex/systems/:id/link` | DELETE | gm/admin | Clear the system's `codex_id` | A new record that waits for review is not linked (`linked: false`): it exists in Grimoire Codex DB only once approved. Book and system detail responses, and the books listed in a system, carry `codex_id` (`null` when unlinked). For API keys these endpoints fall under the `codex` permission. ### Share links Public, revocable URLs for a single library item (issue #541). See [docs/sharing.md](sharing.md) for the feature as users see it. **Who may create them.** Everything is off with `SHARE_LINKS_ENABLED=false`. Otherwise a per-user override (`users.share_links_enabled`, set through `PATCH /api/users/:id` with `share_links`) wins either way; with no override the role decides - admins always, GMs unless `share_links_role_gm` is off, players only once `share_links_role_player` is on. Guests never may. `GET /api/auth/me` reports the result as `share_links_allowed`. **Whether a link works** is decided on every request, never stored. A link stops working when it is revoked, when it expires, when its creator may no longer create share links, or when its creator can no longer read the item (the same check the item's own `/file` route makes - access levels and the explicit-content setting included), or the item is gone. Only revoking is permanent: a link suspended by a lost permission is kept and works again if the permission returns. The management API reports this as `state`: `active`, `disabled`, `revoked`, `expired`, `suspended` or `unavailable` - or `lost`, for a link whose token was stored under a `SECRET_KEY` that has since changed. A lost link can never work again; its `url` and `file_url` are `null`, and it can only be revoked or deleted. #### Management Session or API key (`share-links` permission); never guests. You manage your own links - including after losing the permission, so you can still revoke them - and an admin can list and revoke anyone's. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/share-links` | GET | any | Your links, newest first. Filter with `resource_type`, `resource_id` and `variant`. Each: `{id, url, file_url, resource_type, resource_id, resource_name, label, filename, has_password, variant, variant_options, state, created_at, expires_at, revoked_at, last_used_at, use_count}`. `url` is the share page; `file_url` the raw file. | | `/api/share-links` | POST | may share | Create a link. Body: `{resource_type: "book"\|"map"\|"token"\|"audio"\|"model", resource_id, label?, expires_in_days? (1-3650, null = never), password? (4-200 chars), variant? ("otfbm"), cell? (20-100)}`. `variant: "otfbm"` (maps with a single picture only, `400` otherwise) makes the link serve the map resized as an [OTFBM background](#otfbm-export) at `cell` pixels per cell (the suggested size when omitted); the link then carries `variant: "otfbm"` and `variant_options: {cell}`. `403` when share links are off, you may not share, or you cannot read the item; `404` for an unknown item or a file missing on disk. | | `/api/share-links/permission` | GET | any | `{enabled, allowed, source, override, role_allows}` for you. | | `/api/share-links/all` | GET | admin | Every user's links, with `user_id` and `username`. Signed-in session only: `403` with an API key. | | `/api/share-links/users/:user_id` | GET | admin | One user's links, with `user_id` and `username`. Signed-in session only: `403` with an API key. | | `/api/share-links/:id` | PATCH | owner or admin | Change `label`, `expires_in_days` (null = never) or `password` (null or `""` removes it). Only the fields sent change. `400` for a revoked link. | | `/api/share-links/:id/revoke` | POST | owner or admin | Stop the link working for good. It stays listed as `revoked`. | | `/api/share-links/:id` | DELETE | owner or admin | Remove the link entirely. `{status: "deleted"}`. | Someone else's link is `404` to anyone but an admin. #### Public No authentication. Every way a link can fail to work is the same `404`. | Endpoint | Method | Description | |----------|--------|-------------| | `/api/share/:token/meta` | GET | What the share page shows: `{password_required, unlocked, resource_type, name, label, filename, media_type, media_kind, file_size, file_url, download_url, expires_at}`. Until a password-protected link is unlocked, only the first two fields are set. `media_kind` is `image`, `audio`, `video`, `pdf`, `model` or `file`. | | `/api/share/:token/unlock` | POST | Body `{password}`. Sets an HttpOnly `grimoire_share` cookie, scoped to `/api/share/:token`, that unlocks that one link in this browser for 12 hours. Changing the password invalidates it. `403` for a wrong password. | | `/api/share/:token` | GET, HEAD | The item, inline (`?download=1` for an attachment). `401` when the link needs a password and this browser has not unlocked it. | | `/api/share/:token/:filename` | GET, HEAD | The same; the filename is ignored. It is there for tools that pick a decoder from the URL's extension, which is why `file_url` ends in the served file's real extension. | What is served: a book, token, audio track (with `Range` support) or 3D model serves its file; an image map serves the original image; a PDF map its first page rendered to WebP; a Universal VTT map its embedded image; an archive the archive. Responses carry `Access-Control-Allow-Origin: *` and `Cross-Origin-Resource-Policy: cross-origin` (a WebGL canvas such as Owlbear Rodeo's cannot read an image without them, and no credentials are involved), `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` and `Cache-Control: private, max-age=300`. A password-protected link drops the cross-origin headers and sends `Cache-Control: private, no-store`. Each `GET` adds one to `use_count` and sets `last_used_at`; `HEAD` and range requests that do not start at byte 0 (seeking through audio) are not counted. **Throttling.** Requests for tokens that do not exist count against the auth rate limit per client IP; past it, every share request from that IP gets `429`. Only unknown tokens count - a revoked, expired or suspended link is still a known token - so a tool that keeps fetching a dead link never locks its working links out (those tools fetch every link from one server address). Wrong passwords count separately, per IP and link. ### Add-ons *(admin only)* Community add-ons are installable metadata scrapers, authored in the separate [`grimoire-codex/community-add-ons`](https://github.com/grimoire-codex/community-add-ons) repo. See [`docs/addons.md`](addons.md) for the full picture. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/addons` | GET | admin | Installed add-ons, everything in the cached index, and add-on settings. `source_credentials` maps each configured index URL that has an access token to `{header, env_locked, unreadable}` - never the token | | `/api/addons/refresh` | POST | admin | Re-fetch the community index. Returns `{status, count}` | | `/api/addons/update-all` | POST | admin | Refresh the index, then update every installed add-on with a newer version. Returns `{status, updated: [{id, from, to}], failed: [{id, error}]}` | | `/api/addons/settings` | PATCH | admin | Set `index_url` and/or `allow_scripts`. An index URL must be `https://`, or `http://` on `localhost` or a private network address; anything else is a `422` | | `/api/addons/sources/credential` | PUT | admin | Store a [private source's](addons.md#private-sources) access token. Body `{url, token, header?}`; `url` must be a configured index URL, and `header` defaults to `Authorization` (sent as `Bearer <token>`). `400` for an unconfigured URL, one pinned by `ADDON_INDEX_CREDENTIALS`, or an unusable token or header. Returns `{source_credentials}`. Session only | | `/api/addons/sources/credential?url=` | DELETE | admin | Forget a source's stored token. Returns `{source_credentials}`. Session only | | `/api/addons/:id/install` | POST | admin | Install or update from the index. Body `{approve_script: bool}` | | `/api/addons/:id` | PATCH | admin | Set `enabled` and/or `script_approved` | | `/api/addons/:id` | DELETE | admin | Uninstall and forget its state | **Installed add-on fields:** `id`, `name`, `version`, `kind`, `target`, `description`, `homepage`, `author`, `author_url`, `attribution`, `requires_script`, `script_approved`, `enabled`, `runnable`, `blocked_reason`, `source`, `available_version`, `update_available`. `runnable` is false (with a human-readable `blocked_reason`) when an add-on is disabled, or is script-backed and lacks either consent. `author` is who wrote the add-on - a GitHub username or display name, shown as a "by <author>" byline. It is distinct from `attribution`, which credits the upstream data source the add-on scrapes; both are `""` when the manifest omits them. `author_url` is **derived by the server**, never taken from the manifest: when `author` is a valid GitHub username (a leading `@` is accepted, as is a bare `https://github.com/<username>`), it resolves to that profile, and is `""` otherwise. Because the manifest cannot supply a URL of its own, a `javascript:` value, another host, or a `github.com` lookalike can never be linked. The name itself always renders as plain text - only a separate GitHub icon is clickable. **Available add-on fields:** the index entry plus `installed` and `update_available`. The index relays `author` too, so the byline shows before an add-on is installed. ### Themes Colour themes, installed **per user**. Any authenticated user may install and select their own; only the catalogue URL is admin-only, being server-wide configuration. See [`docs/themes.md`](themes.md). | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/themes` | GET | user | The user's installed themes plus their selection in one app mode and the catalogue settings. `?app_mode=` selects which (default `grimoire`) | | `/api/themes` | POST | user | Install a pasted/uploaded theme. Body is the theme document. Works even when external installs are disabled | | `/api/themes/browse` | GET | user | The community catalogue, each entry marked `installed`. 403 when downloads are disabled, 502 when the catalogue is unreachable | | `/api/themes/selection` | PUT | user | Set `mode` (`light`/`dark`/`system`) and/or `theme_id` (`""` clears it) for one `app_mode` | | `/api/themes/source` | PUT | **admin** | Set the catalogue `index_url` | | `/api/themes/install/:id` | POST | user | Install one theme from the catalogue into the user's account | | `/api/themes/:id` | DELETE | user | Uninstall a theme. Also deselects it if it was active | **Listing fields:** `installed[]`, `built_in[]`, `app_mode`, `app_modes[]`, `mode`, `theme_id`, `downloads_enabled`, `index_url`, `default_index_url`, `is_custom_url`. **Theme fields:** `id`, `name`, `mode`, `modes[]`, `app_mode`, `variants`, `tokens`, `source_id`, `source_url`, `source_version`, `is_community`. **Catalogue entry fields** (from `/api/themes/browse`): `id`, `name`, `description`, `mode`, `app_mode`, `modes[]`, `version`, `author`, `author_url`, `path`, `sha256`, `grimoire_min_version`, `installed`. `author`/`author_url` are the same credit and derived profile link add-ons and note templates carry. `variants` maps a colour mode to its token set, so one theme can cover light and dark and **System** can follow the OS within it. `modes[]` lists what it covers, for a picker row reading "light & dark"; `mode` is the primary one and `tokens` duplicates that variant. A theme sent with only `tokens` is read as covering its one `mode`, so the older shape keeps working. **App modes** are `grimoire` (TTRPG) and `codex` (wargaming), a second axis alongside the light/dark colour `mode`. Selection is stored per app mode, so switching restores what was chosen there. `built_in[]` lists the bundled themes - their colours live in the stylesheet, so they carry no `tokens` and can be selected without being installed. A theme's own `app_mode` is a preference the picker sorts by, not a restriction. Token names are validated against a closed allowlist and values against a plain-colour grammar, on write **and** on read. Anything else is dropped; a theme setting nothing recognisable is rejected with a 400. Downloaded themes are verified against the catalogue's SHA-256 and pinned to the catalogue's host. **Updates:** a scraper definition is expected to change whenever its source does, so `available_version` and `update_available` are reported on each *installed* add-on (not just the available list) - an update is only actionable if it is visible on the row the admin is looking at. Versions compare as semver, so `1.10.0` is correctly newer than `1.9.0` and a downgrade in the index is never offered as an update. Neither is a version whose index entry declares a `grimoire_min_version` above the running build: installing one is refused with a 400 before anything is downloaded, since the manifest may map a field this build lacks. A running version of `unknown` (a source checkout with no `VERSION` file) is never blocked. Applying an update is the same `POST /api/addons/:id/install` call, and the downloaded manifest is validated before it replaces the installed one, so a failed update leaves the previous version working. `update-all` continues past individual failures rather than aborting the batch. An add-on installed by hand has no index entry and therefore never reports an update. **Script safety:** an add-on may ship a Python script for sources YAML cannot express. Grimoire runs one only when `allow_scripts` is on **and** that add-on was approved at install time (`approve_script: true`). Scripts execute in an isolated subprocess with a timeout and no database access. Approval is tied to the script's digest, so an update that changes the script drops back to unapproved - including via `update-all`, which never silently re-grants consent. Downloads are verified against the SHA-256 the index declares and refused on mismatch. **Storage:** installed add-ons live in `DATA_PATH/add-ons/<id>/`; a directory placed there by hand works without any UI step. Config and install state ride in the generic `app_settings` table under `addons.*` keys, so this feature adds no schema. ### Characters Character sheets and the schemas that describe them, both stored **per user**. A schema is a JSON document defining a sheet's fields, computed values, and layout; a character is one set of answers to it. Grimoire core renders any schema without game-specific code. Like themes, and unlike add-ons, these are per-account: installing a sheet cannot affect anyone else, so any authenticated user may install one and there is no admin approval. Guests included - a guest account exists to play in one campaign, which is exactly who wants a character sheet. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/characters/schemas` | GET | user | The user's installed schemas, each with a `character_count` | | `/api/characters/schemas` | POST | user | Install a pasted sheet (see below). 400 with a message naming the problem if it does not validate | | `/api/characters/schemas/browse` | GET | user | The community catalogue of sheets, each marked `installed` | | `/api/characters/schemas/install/:sheet_id` | POST | user | Install one sheet from the catalogue | | `/api/characters/schemas/:schema_id` | GET | user | One schema with its validated `document` | | `/api/characters/schemas/:schema_id` | DELETE | user | Uninstall a schema. Characters built on it are **kept** | | `/api/characters` | GET | user | The user's characters plus their parties', newest first. `?schema_ref=` filters by schema, `?campaign_id=` by campaign | | `/api/characters` | POST | user | Create a character. Body is `{schema_ref, name?, data?, campaign_id?}`. 400 if that schema is not installed, 403 if the campaign is not one you own or have joined | | `/api/characters/:id` | GET | user | One character with its `data` and freshly evaluated `computed` | | `/api/characters/:id` | PUT | user | Update `name`, `data`, `unset`, `campaign_id`, `status`, `portrait_crop` and/or `gm_can_edit`. `data` is a **partial patch** - only the fields it names are touched. See below for the reserved keys. The owner, or the campaign's GM when `gm_can_edit` is on; a GM sending `campaign_id`, `status` or `gm_can_edit` gets 403, anyone else 404 | | `/api/characters/:id` | DELETE | user | Delete a character. Owner only | | `/api/characters/import` | POST | user | Rebuild a character from an exported file | | `/api/characters/importers` | GET | user | `{importers, available, downloads_enabled}`. `importers` are the ones you can run - the server's and your own - each with `scope` (`server`/`user`), the `sheet` it builds on, `sheet_installed`, `extensions`, `accepts_files`, `accepts_url`, the `inputs` it asks for (`{key, label, help, secret, required, when}`), and `available_version` when your own copy has an update. `available` are catalogue importers you lack, each with `install_as`: `user` (install it yourself), `server` (an admin installs it for everyone via `POST /api/addons/:id/install`), `settings` (an admin installs it from Settings, where a script's consent is asked) or `admin` (ask one). An empty add-on index is refreshed once from the configured sources | | `/api/characters/importers/:id/install` | POST | user | Install a catalogue importer into your own account, or update your copy. Only one that reads files with YAML - the downloaded manifest is checked, whatever the index says; 400 otherwise, 403 when installs are switched off | | `/api/characters/importers/:id` | DELETE | user | Remove an importer you installed. Characters imported with it are kept | | `/api/characters/import/external` | POST | user | Build a character from another tool's export. Body: `{text, filename}` for a file or `{url}` for a link, plus optional `importer_id`, `campaign_id` and `inputs` (`{key: value}` answers to the importer's declared inputs - passed to its script for this import only, never stored; undeclared keys are dropped). The importer is chosen from the file's extension and contents (or the link) unless `importer_id` names one; 409 when several claim it. The importer's sheet is installed from the catalogue if missing. Returns the character plus `importer_id`, `importer_name`, and `unmatched` - names kept as custom entries because no installed content matched. When an input is missing - a required one, or one the script asks for, such as a key for a private character - the answer is 400 with `detail: {message, importer_id, needs_input: [keys]}`; send the import again with `importer_id` and `inputs` | | `/api/characters/:id/export` | GET | user | Export a character as a self-contained file | | `/api/characters/:id/portrait` | POST | user | Set a portrait (PNG/JPEG/WebP/GIF, 5 MB). The owner, or the campaign's GM when `gm_can_edit` is on | | `/api/characters/:id/portrait` | GET | user | The portrait image | | `/api/characters/:id/portrait` | DELETE | user | Remove the portrait. The owner, or the campaign's GM when `gm_can_edit` is on | | `/api/characters/:id/images/:field` | POST | user | Set the picture an `image` field holds (multipart `file`; PNG/JPEG/WebP/GIF, 5 MB). The owner, or the campaign's GM when `gm_can_edit` is on; 404 unless the sheet declares `field` as an image. Returns `{field, value}` | | `/api/characters/:id/images/:field` | GET | user | An image field's picture, to anyone who can read the character | | `/api/characters/:id/images/:field` | DELETE | user | Remove an image field's picture, file and value. Also clears one a sheet update left behind. Same access as setting one | **Installing a pasted sheet** takes the sheet either already parsed, as `document`, or as text: ```json { "text": "id: cairn\nname: Cairn\nfields: {...}", "layout": "<div class=\"sheet\">...</div>", "styles": ".sheet { display: grid; }" } ``` `text` is parsed as **JSON and then as YAML**, the same rule add-on indexes follow. YAML is the friendlier of the two for a hand-written sheet: no quoting every key, no trailing-comma errors, and comments are allowed. YAML aliases (`*name`) are not supported, and a document nested more than 100 levels deep is refused; either is a 400, as is text over 512 KB. `layout` and `styles` carry a custom sheet's HTML and CSS as their own fields rather than escaped into the document, which is how the community repository keeps them too. Given here they **override** whatever the document holds, and `layout_file`/`styles_file` pointers are dropped — what gets stored is one self-contained document, so the sheet still renders if its catalogue moves. A pasted layout goes through the same tag allowlist as a downloaded one, and a pasted stylesheet through the same property filter. Pasting is not a way around either. **Updating a character.** `data` is merged into what is stored; a value the schema does not declare is dropped rather than stored. `unset` is a list of field names to remove, which is how a field goes back to its `default_from` value after the player typed over it - separate from `data`, because a `null` there already means "cleared" mid-edit. Two reserved keys may ride in `data` beside the fields, each checked strictly rather than stored as sent: | Key | Shape | Purpose | |---|---|---| | `_overrides` | `{computed name: value}` | The player's own value for a computed value. It replaces the formula, and everything depending on it follows. Names must be computed values; values must be short scalars. Sent whole - leaving a name out resets it | | `_granted` | `{picking field: {target field: [value, ...]}}` | What each `on_pick` rule added, so changing the pick can take it back off. The source must carry `on_pick` and each target must be a declared field | **Schema fields:** `id`, `schema_id`, `name`, `system`, `description`, `version`, `source_id`, `source_url`, `source_version`, `is_community`, `character_count`, and on detail `document`. **Character fields:** `id`, `name`, `schema_ref`, `schema_name`, `system`, `schema_missing`, `user_id`, `owner_name`, `campaign_id`, `campaign_name`, `campaign_has_gm`, `gm_can_edit`, `status`, `portrait_path`, `portrait_version`, `portrait_crop`, `owned`, `editable`, `created_at`, `updated_at`, and on detail `data`, `computed`, `validators`, and `entries`. `user_id` is the character's player and `owner_name` their display name (or username), so a campaign page can group its characters by player - including a player who has since left the table. `owned` is whether the caller is the character's player; `editable` is whether they may edit it. A campaign's owner and accepted members can read a character placed in it, and only its player edits it - unless the player sets `gm_can_edit`, which lets the campaign's owner (its GM) edit the sheet too: its `name`, `data`, `unset`, `portrait_crop`, portrait and image fields. Other players at the table still only read it, and `campaign_id`, `status`, `gm_can_edit` and deleting stay the player's. `gm_can_edit` is `false` for a new character and is reset to `false` whenever `campaign_id` changes (unless the same request sets it). `campaign_has_gm` is true when the character's campaign is owned by someone other than its player - the only case where `gm_can_edit` means anything. A GM's edits are checked against the player's copy of the sheet. `status` is `active` (the default), `retired` or `dead`. A player may have any number of characters in one campaign; when one falls they mark it rather than delete it, and roll the next. Any other value is a 400. `campaign_name` is the campaign's name, or `null` without one. `portrait_version` changes whenever the portrait does (it is `null` without one), and the portrait upload returns it too. Pass it as `?v=` on the portrait's URL: the image is cached for five minutes, so the same URL would keep serving the old art after a replacement. `portrait_crop` is the player's framing of the portrait: `{x, y, zoom}`, the focus point as percentages across and down the image (0-100) and a zoom from 1 to 4. Clients draw the portrait with `object-fit: cover`, `object-position` at the focus point and a scale about it, so every frame - whatever its shape - keeps that point in view. `null` is centred and unzoomed; sending the defaults (`{}`) clears it, and uploading or removing the portrait resets it. **Image fields.** A field declared `"type": "image"` stores the uploaded file's name in `data` (`coat_of_arms-<16 hex>.png`), never the picture. Only the image routes write it: a value sent in `data` on create, update or import is dropped, and `unset` leaves it alone. The name changes with every upload, so put it in the picture's URL as `?v=` to bust the cache, as with `portrait_version`. A character's pictures are deleted with it (and with its owner's account), and installing a new version of a sheet deletes any whose field is gone or is no longer an image. **Exports** carry the pictures as `data:` URLs - `portrait`, `portrait_crop`, and `images` (`{field: data URL}`) - and an import stores each one through the same checks as an upload, for the fields the sheet declares as images. A character importer fills an image field the same way, with a `data:` URL. #### Campaign scoping Setting `campaign_id` puts a character on a table, and every member of that campaign can then **read** the sheet — which is the point, since a GM should be able to see what their players are playing. Writing stays with the owner: `owned` is false when you are reading someone else's, and edits, deletes and portrait changes answer 404. `campaign_id: ""` takes a character back out. "At the table" means the campaign's **owner** or an **accepted** member. That covers a GM campaign (the GM reads every player's sheet), a player's personal campaign (which has no members at all), and a campaign you joined. A character may only be placed in such a campaign - 403 otherwise, including for one you have been invited to but not yet joined. Campaign rulesets are readable by the same people. A character with no campaign stays private to its owner. `GET /api/characters` returns your own plus your parties', and `?campaign_id=` narrows it to one table's roster. #### Export and import Export is **denormalised**: every reference is resolved and the entry's full data embedded, and the schema travels with the file. That is the portability guarantee — a character shared with someone whose instance has neither the pack nor the ruleset still opens and still computes correctly. ```json { "$schema": "grimoire://character/v1", "name": "Vex", "status": "active", "schema_id": "dnd-5e", "schema": { "...the whole sheet definition..." }, "data": { "spells": [ { "_ref": "my-spell" } ] }, "entries": { "my-spell": { "content_type": "spell", "source": "ruleset", "name": "My Spell", "data": { "level": 4 } } } } ``` Importing installs the schema if the importer does not have it, and recreates embedded entries in a ruleset named after the import — but **only what is missing**. An entry this instance already has, in a pack or in any ruleset the importer can read, wins over the embedded copy, so an erratum applied locally reaches an imported character. The new ruleset is **personal** - only the importer can read or edit it - so importing a character never publishes its content to anyone else. The character still reads correctly for a party it later joins, because a sheet resolves entries against its owner's content. Pass `import_entries: false` to skip that and let the references read as missing instead. The file's `status` is kept if it is one this instance knows, and read as `active` otherwise. Anyone who may read a character may export it, so a GM can archive a party member's sheet. **Computed values are never stored.** They are evaluated from `data` on every read, so correcting a formula in a schema immediately fixes every character built on it rather than leaving stale numbers behind. **Submitted values are coerced to their declared type** and anything the schema does not declare is dropped. Out-of-range numbers are clamped rather than rejected, because refusing to save a whole character over one out-of-range score would lose the player's work. **A character outlives its schema.** `schema_ref` is a soft reference by `schema_id`, not a foreign key, so uninstalling a sheet leaves its characters readable: they come back with `schema_missing: true`, an empty `computed`, and their stored `data` intact. Reinstalling the schema restores the full sheet. #### The sheet catalogue Browsing and installing need only an account — a sheet lives in one user's account and changes nothing for anyone else, so there is no admin step, exactly as with themes. The catalogue URL is **derived from the add-on index** the admin already configured rather than being a second setting. `.../main/index.json` and `.../main/themes/index.json` both resolve to `.../main/character-sheets/index.json`, so pointing the server at a branch points every catalogue at it — themes, note templates and sheets together. **Several sources** are supported, as for add-ons and themes: the add-on index setting takes a comma-separated list, and every entry is consulted. Because two catalogues may each offer a sheet with the same id, a listed `id` is **namespaced by its source** (`cairn-a1b2c3d4`) while `raw_id` is what the sheet calls itself. Install by the namespaced id to choose a particular source's copy; a bare id still works and resolves to the first source offering it. `installed` is matched on the source as well as the id, so installing one catalogue's `cairn` does not mark another's. A schema pasted in by hand has no recorded source and therefore marks every copy of its id, since installing any of them would replace it. `sources` lists every catalogue consulted and `errors` the ones that could not be read — reported rather than dropped, because with several configured a missing source otherwise just looks like a smaller catalogue. **Catalogue entry fields:** `id`, `name`, `version`, `system`, `description`, `author`, `author_url`, `homepage`, `license`, `license_url`, `attribution`, `custom_layout`, `field_count`, `grimoire_min_version`, `path`, `sha256`, `index_url`, `installed`. The attribution is carried in the listing so it can be read **before** installing, and is rendered verbatim. A downloaded sheet is verified against the catalogue's SHA-256 and pinned to the catalogue's host: a catalogue may say where its files are, but not send the server somewhere else. It is then validated by the same schema validator a pasted sheet goes through, so a sheet that does not validate is refused rather than stored. Nothing in a sheet executes. A source that cannot be read is skipped rather than failing the whole browse — one unreachable branch should not hide the sheets that are fine. Browsing answers 403 when `DISABLE_EXTERNAL_ADD_ON_INSTALL` is set, and 502 when the catalogue is unreachable. #### Schema documents ```json { "id": "dnd-5e", "name": "D&D 5e", "version": "1.0.0", "fields": { "strength": { "type": "number", "label": "Strength", "min": 1, "max": 20 } }, "computed": { "str_mod": { "formula": "floor((strength - 10) / 2)", "label": "STR Mod" } }, "layout": [{ "title": "Abilities", "fields": ["strength", "str_mod"] }] } ``` Field types are `text`, `number`, `textarea`, `checkbox`, `select`, `multiselect`, and `list`. Formulas are a small arithmetic/comparison/logic language with a closed function table (`floor`, `ceil`, `round`, `abs`, `min`, `max`, `sum`, `len`, `if`, `clamp`, `signed`, plus the list functions below). They are **parsed, never executed** - there is no `eval` anywhere in the engine - and a formula referencing an unknown name is rejected at install rather than failing at render. #### List fields A `list` is a repeatable table - equipment, attacks, spell slots. Each column is itself a field definition, so a column may be any scalar type (`text`, `number`, `checkbox`, `select`, `textarea`) but **not** another list: ```json { "equipment": { "type": "list", "label": "Equipment", "columns": [ { "key": "name", "type": "text", "label": "Name", "flex": 3 }, { "key": "qty", "type": "number", "label": "Qty", "default": 1 }, { "key": "equipped", "type": "checkbox", "label": "Eq." } ] } } ``` Every row is rebuilt from the declared columns on save, so a key the schema does not define is dropped - the same rule top-level fields follow. A missing cell falls back to its column's `default`. A list is capped at 500 rows. Five functions read across rows, taking the **column name as a string** (a bare name would resolve against the character before the function saw it): | Function | Returns | |---|---| | `count_where(list, 'col')` | How many rows have that column truthy (or equal to a third argument) | | `sum_where(list, 'col')` | Total of that column, optionally filtered: `sum_where(kit, 'qty', 'equipped', true)` | | `sum_qty(list, 'col')` | Total of that column times each row's `qty` (or a named third column): `sum_qty(gear, 'mass')`. A blank quantity counts once. Also reads a catalog list, with each entry's per-entry quantity | | `any_where(list, 'col')` | Whether any row matches | | `column(list, 'col')` | Every value of one column, for `sum()`/`min()`/`max()` | | `contains(value, x)` | Whether a multiselect holds `x`, or text contains it | #### Conditional fields - `visible_if` A field, or a whole `layout` section, may carry a `visible_if` expression and is drawn only when it is true. In an HTML layout it is an attribute on `g-field`, `g-computed` or `g-section`, alongside the existing `<g-if test="...">`: ```json { "spell_dc": { "type": "number", "label": "Spell DC", "visible_if": "is_caster" } } ``` A condition that cannot be evaluated **shows** the field. Hiding part of a sheet over a broken expression would cost the player access to their own data. #### Validators A schema may declare rules that check a character. A rule states what *should* be true, so a false result is what gets reported: ```json { "validators": [ { "rule": "count_where(equipment, 'equipped') <= 2", "severity": "warning", "message": "More equipped than you can carry" } ] } ``` `severity` is `warning` or `error`; `message` is required, because a rule that fires without saying why is not actionable. Rules may read fields and computed values alike, and are rejected at install if they do not parse or name something that does not exist. Results come back on `GET`/`POST`/`PUT` of a character as `validators[]`, each `{rule, message, severity, field}`. **They never block saving.** A sheet mid-edit is routinely invalid - you pick the spells before you raise the level that allows them - and refusing the write would lose the player's work. **HTML layouts.** A schema may replace the JSON `layout` with `layout_html`, an HTML *template*, plus an optional `styles` block. A catalogue sheet should keep both in sibling `.html` and `.css` files instead — HTML escaped into a JSON string is unreadable — and the catalogue then carries `layout_path`, `layout_sha256`, `styles_path` and `styles_sha256`. Each file is downloaded and digest-checked separately, then folded into one self-contained document, so the stored sheet does not depend on the catalogue still being there. Both are validated at install and returned as derived `layout_ast` / `styles_css`: ```html <div class="sheet"> <g-section title="Abilities"> <g-field name="strength" /><g-computed name="str_mod" /> </g-section> <g-if test="level > 4"><g-field name="feat" /></g-if> </div> ``` Directives are `g-field`, `g-computed`, `g-label`, `g-value`, `g-section`, `g-if`, and `g-repeat`. Inside a `g-repeat` over a list field, a `g-field` naming one of that list's columns addresses **that row's** cell, so a custom layout can draw an editable table of its own. Everything else is a closed allowlist of structural tags. **The template never becomes markup**: it is parsed to an AST server-side and rendered as React elements, so no schema string ever reaches `innerHTML`. Event handlers, `<script>`, `<style>`, `<iframe>`, `<input>` and friends, and any `href`/`src` that is not relative or https are rejected at install time. Schema CSS is scope-prefixed to the sheet and filtered against a property allowlist, so a hostile schema cannot restyle the app around it. The worst one can do is look wrong. ### Content catalog A **content pack** is a directory of typed entries - spells, classes, feats, kits - for one game system. A character references an entry rather than copying it, so an erratum or a ruleset edit reaches every character built on it. Packs are **server-wide**: installed once into `DATA_PATH/character-content/`, loaded on startup and rescan, and shared read-only. Schemas stay per user, so the catalog is *content everyone has, described by the caller's own copy of the sheet* - two people may have different versions of a schema installed, and each browses the content types their copy declares. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/content/packs` | GET | user | Installed packs with their licence and credit. `?schema_id=` filters | | `/api/content/packs/reload` | POST | **admin** | Re-read every pack from disk | | `/api/content/:schema_id/types` | GET | user | The content types this user's schema declares, each with an entry count | | `/api/content/:schema_id/:content_type` | GET | user | Browse: `search`, `filter[field]=value`, `sort`, `page`, `page_size` | | `/api/content/:schema_id/:content_type/:entry_id` | GET | user | One entry in full | | `/api/content/:schema_id/resolve?ids=a,b,c` | GET | user | Resolve many references at once, for rendering a sheet | **Browse response:** `entries[]`, `total`, `page`, `page_size`, and `filters_available` - facets built from the content type's `filter_fields`, each value with the number of entries carrying it. Facets describe the **whole** catalog rather than the current page, so choosing one value does not empty every other facet and leave no way back. Numeric facet values sort numerically, so a spell-level filter reads 1, 2, 3, 10 rather than 1, 10, 2, 3. Search is FTS5 over each type's `search_fields`, prefix-matched per term. A query is reduced to bare words first, so an unbalanced quote or a stray operator cannot raise. **Resolve** marks an id no installed pack provides as `missing` rather than omitting it, so a sheet can say "this entry is not installed" instead of silently dropping something the player chose. #### `content_types` A content type is a field schema applied to catalog entries rather than to a character, so its `fields` are validated exactly as a sheet's are and render through the same `FieldRenderer`: ```json { "content_types": { "spell": { "label": "Spell", "label_plural": "Spells", "identity_field": "name", "sort_default": ["level", "name"], "search_fields": ["name", "school", "description"], "filter_fields": ["level", "school"], "compact_display": "{name} — {school} {level}", "fields": { "name": { "type": "text" }, "level": { "type": "number" } } } } } ``` `identity_field` is the entry's name - what a reference displays and what the catalog sorts by. The three field-name lists and every `{token}` in `compact_display` are checked against the type's own fields at install, so a typo fails loudly rather than producing an empty filter sidebar. #### `content_ref` and `content_list` Two field types pick from the catalog. `content_ref` takes one entry (a class, a kit); `content_list` takes many (spells known, feats). Both name the `content_type` they draw from, which must be one the schema declares. ```json { "spells": { "type": "content_list", "content_type": "spell", "allow_freeform": true, "per_entry_fields": { "prepared": { "type": "checkbox" } } } } ``` `per_entry_fields` are the **character's own** notes on an entry - prepared, equipped, uses remaining. They are ordinary field definitions, validated and coerced like any other. **Stored shape.** A reference, never a copy: ```json { "signature": { "_ref": "fireball", "_source": "srd" }, "spells": [ { "_ref": "fireball", "_source": "srd", "_per": { "prepared": true } }, { "_inline": true, "name": "A spell I made up" } ] } ``` `_source` records which pack an entry came from, because two packs for one system may each define `fireball`. `_inline` is the freeform escape hatch that keeps the catalog optional: with `allow_freeform`, a player can type an entry instead of picking one, and never open the browser at all. A freeform entry keeps the properties its content type declares - a homebrew spell's level and description - coerced to their types; anything undeclared is dropped. A character detail response carries `entries` - the catalog entries its references point at, resolved in one query - so rendering the sheet costs no extra request. A reference whose entry is not installed is **kept**: the pack may come back, and erasing the player's choice would be worse than showing an id. #### Catalog functions Four more expression functions read referenced entries. The resolved table is supplied behind the scenes, so an author never writes it: | Function | Returns | |---|---| | `ref(field, 'prop')` | One property of a single reference | | `sum_refs(list, 'prop')` | That property totalled across every referenced entry | | `has_ref(list, 'entry-id')` | Whether the list holds that entry | | `count_refs(list)` / `count_refs(list, 'prop', value)` | How many references there are, or how many match | The character's `_per` notes are layered over the catalog entry - and over a freeform entry's own values - so `count_refs(spells, 'prepared')` reads what the player set. Before the catalog has resolved, these return 0 rather than failing - a sheet renders while its entries are still loading. #### Pack layout ``` DATA_PATH/character-content/dnd-5e-srd/ ├── _meta.json ← pack_id, schema_id, licence, source ├── spell.json ← an array of entries, named after the content type └── class.json ``` Every entry needs an `_id`; `_source` defaults to the pack's. A pack that sets a `license` **must** carry an `attribution`, which Grimoire renders verbatim - several open licences mandate exact wording. Loading a pack replaces its entries wholesale, so the directory is always the source of truth, and a pack whose directory disappears loses its rows on the next scan. A pack may be installed before anyone has installed the matching sheet; its entries are stored as authored until a schema describes them, so install order does not matter. ### Rulesets A **ruleset** is a named set of catalog entries — an SRD, a supplement, a table's house rules. Same data shape as pack content, validated against the same content type and rendered by the same component. What differs is that a ruleset is **editable** and **scoped**, where pack content is neither. Editing an SRD entry means **forking** it into a ruleset. Scope is the point. Two games can run the same system and allow different content, which is not something a per-user model can express: | Kind | `campaign_id` | Who reads it | Who edits it | |---|---|---|---| | **Campaign** | the campaign | everyone at that table: the campaign's owner and its accepted members | the campaign owner, and admins | | **Personal** | null (with an owner) | its owner only | its owner only - not even admins | | **Server** | null | everyone on the instance | admins | A campaign ruleset is deleted with its campaign — the content existed to serve that table. Creating a server ruleset requires admin; creating a campaign one requires owning the campaign. A personal ruleset is not created through this endpoint: importing a character makes one, to hold the content embedded in the file. It is deleted with its owner's account, and moves with it when a guest is merged into another account. | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/rulesets` | GET | user | Rulesets this user can read. `schema_id`, `campaign_id` filter | | `/api/rulesets` | POST | user | Create one. `campaign_id` null asks for a server ruleset (**admin**) | | `/api/rulesets/packs/browse` | GET | user | The community catalogue of content packs, each with `installed`, `installed_version` and `update_available`. Also returns `can_install` for this caller | | `/api/rulesets/packs/install/:pack_id` | POST | **admin** | Download and install one pack, then load it into the catalog. Replaces an installed copy, which is how a pack is reinstalled or updated | | `/api/rulesets/packs/:pack_id` | DELETE | **admin** | Uninstall a pack: its directory and its rows. Rulesets that imported it keep their copies | | `/api/rulesets/installable` | GET | user | Installed content packs that can be imported into a ruleset | | `/api/rulesets/:id` | GET | user | One ruleset, if they may read it | | `/api/rulesets/:id` | PUT | editor | Rename it or change its credit | | `/api/rulesets/:id` | DELETE | editor | Delete it and its entries | | `/api/rulesets/:id/export` | GET | user | The whole ruleset as a portable document | | `/api/rulesets/:id/import` | POST | editor | Import a `pack_id`, a `document`, or that document as `text` (JSON or YAML). `conflict` is `skip` (default), `overwrite`, or `rename` | | `/api/rulesets/:id/fork` | POST | editor | Copy a catalogue or readable ruleset entry into this one | | `/api/rulesets/:id/entries` | GET | user | Its entries. `content_type` filters | | `/api/rulesets/:id/entries` | POST | editor | Write an entry | | `/api/rulesets/:id/entries/:entry_id` | GET | user | One entry, with its `data` | | `/api/rulesets/:id/entries/:entry_id` | PUT | editor | Edit an entry | | `/api/rulesets/:id/entries/:entry_id` | DELETE | editor | Delete an entry | "editor" above means whoever may edit that ruleset per the table: the campaign owner for a campaign ruleset, an admin for a server one. Everyone else gets 403. **Ruleset fields:** `id`, `schema_id`, `name`, `description`, `version`, `license`, `license_url`, `attribution`, `source_pack_id`, `campaign_id`, `campaign_name`, `personal`, `editable`, `entry_count`, `created_at`, `updated_at`. **Entry fields:** `id`, `ruleset_id`, `content_type`, `entry_id`, `name`, `forked_from`, `editable`, and on detail `data`. `editable` is computed per caller, so the UI can show a read-only view to a player at the table without a second request. #### Installing the SRD Core content reaches a table in two steps. First an admin installs the **pack**, either from the community catalogue (`GET /api/rulesets/packs/browse`, then `POST /api/rulesets/packs/install/:id`) or by dropping a directory into `DATA_PATH/character-content/`, which is loaded at startup. Installing from the catalogue writes the same directory, so the two routes converge - the filesystem stays the source of truth. Every file is verified against its own digest, and the whole pack is staged and swapped into place, so a failed download leaves the previous copy standing. Browsing needs only an account; **installing needs an admin**, because a pack is server-wide. `browse` returns `can_install` for the caller so a GM's UI can show what a pack offers without offering a button that would 403. Then `GET /api/rulesets/installable` lists what is installed, and a GM imports it into their campaign's ruleset with `POST /api/rulesets/:id/import` and `{"pack_id": "..."}`. An import **copies the pack's credit onto the ruleset** — `license`, `license_url`, `attribution` and `source_pack_id` — so content taken from the SRD stays attributed wherever it is shown. Importing a pack built for another system is refused (400), as is a `pack_id` that is not installed (404). #### Forking `POST /api/rulesets/:id/fork` copies one entry into a ruleset you can edit, appending ` (copy)` to its identity field and recording `forked_from` as `"<source>:<entry_id>"`. The source may be a pack entry or an entry in any ruleset the caller can read, so a table can take a server ruleset's spell and change it locally. Forking the same entry twice yields two distinct entry ids rather than a conflict. #### In the catalog Ruleset content is merged into catalog results and tagged, so "Fireball (SRD)" and "Fireball (House Rules)" are told apart: each row carries `ruleset`, `ruleset_id` and `ruleset_name`. Pass `include_rulesets=false` to browse pack content alone. Characters resolve ruleset references exactly as they resolve pack ones, so a formula reading a spell's level does not care where it came from. The FTS index covers pack content; ruleset content is matched in Python on the same terms, because it changes on every edit and the readable set is small. #### Deleting, and dangling references Deleting an entry leaves characters alone. A reference is soft, so the sheet shows it as missing rather than losing the row — the same behaviour as uninstalling a pack, and it means a delete can never destroy someone's character. #### Export format ```json { "$schema": "grimoire://ruleset/v1", "name": "House Rules", "schema_id": "dnd-5e-2024", "version": "1.0.0", "entries": { "spell": [ { "_id": "hellfire-blast", "name": "Hellfire Blast" } ] } } ``` The same shape a filesystem content pack uses, so an exported ruleset can become an installed pack. Import defaults to `skip` because an import should not silently overwrite someone's work, and returns a tally (`imported`/`skipped`/`renamed`/`overwritten`/`failed`) rather than stopping at the first conflict, so a partly-overlapping pack still imports what it can. ### Duplicates *(admin only)* Finding files that look like copies of one another, and deciding what to do about them (issues #304, #306). | Endpoint | Method | Description | |----------|--------|-------------| | `/api/duplicates/scan-status` | GET | Progress of the detection scan. `heartbeat` is the last time the running scan touched its status - it stops advancing when the process behind the scan dies | | `/api/duplicates/scan` | POST | Start a scan. Body: `resource_types` (empty = every collection), `accuracy`. `{status: "already_running"}` if one is genuinely in progress; a scan left stuck by a killed process is discarded rather than blocking this | | `/api/duplicates/cancel-scan` | POST | Stop a running scan. `{status: "stop_requested"}` for a live one, `"cleared_stale"` when it cleared a scan that was no longer really running, `"not_running"` when there was nothing to stop | | `/api/duplicates/groups` | GET | Candidate groups from the last completed scan. Query: `resource_type`, `min_confidence`, `limit` (max 200), `offset`. `total` counts the open groups walked to fill the page, not the whole table - a short page means the end | | `/api/duplicates/compare` | GET | Side-by-side data for 2–4 items. Query: `resource_type`, repeated `ids`. `mergeable_fields` lists what `merge-metadata` may copy | | `/api/duplicates/link` | POST | File items under a parent as its variants. Optional `parent_kind`/`parent_label` name the parent too | | `/api/duplicates/describe` | POST | Set the `kind` and `label` one version shows in the picker - a variant or the main version. Body: `resource_type`, `id`, `kind`, `label` | | `/api/duplicates/promote` | POST | Make a different copy the main version of an existing family | | `/api/duplicates/unlink` | POST | Promote variants back to standalone entries | | `/api/duplicates/merge` | POST | Merge copies into one: metadata, every reference and variant family move to `keep_id`, then the others are deleted. `dry_run` reports exactly that without changing anything - see [Merging](#merging) | | `/api/duplicates/merge-batch` | POST | Several merges behind one confirmation, each committed on its own. Body: `merges: [{resource_type, keep_id, merge_ids}]` (max 200), plus `fields`, `delete_files`, `dry_run` for all of them. Returns `{results: [{keep_id, ok, detail, result}], merged, failed}` - a refused merge is reported and the rest still run | | `/api/duplicates/merge-metadata` | POST | Copy chosen metadata fields between two records | | `/api/duplicates/items/:type/:id` | DELETE | Delete one record, optionally its file | | `/api/duplicates/dismiss` | POST | Record that a group is *not* duplicates | | `/api/duplicates/dismissals` | GET | List dismissed groups | | `/api/duplicates/dismissals/:id` | DELETE | Undo a dismissal | **Nothing is ever deleted automatically.** Detection only surfaces candidates; every deletion, link, and metadata copy is one explicit request about one group the user looked at. There is no setting that changes this. **Recovering a stuck scan.** Scan status lives in Valkey when configured and in a per-process dict otherwise, and only the thread running a scan clears its own `running` flag. A process killed mid-scan therefore left the status running forever: `/cancel-scan` only sets a flag for a thread that no longer exists, and `/scan` refused as `already_running`. The status now carries a `heartbeat` that every progress update refreshes; a running status whose heartbeat has not moved for five minutes is treated as abandoned, so `/cancel-scan` clears it outright (`cleared_stale`) and `/scan` discards it instead of refusing. Startup clears it too, in every worker. A *live* scan is still only ever asked to stop, since it has partial results to discard on the way out. **Detection** is an explicitly triggered scan, never part of the normal rescan, and it refuses to start (409) while a library scan is running. Three signals, each catching what the others cannot: - `hash` — byte-identical files, from the `content_hash` the scanner already stores. Confidence 1.0, and free: no file is re-read. - `metadata` — near-identical titles and authors, catching `book.pdf` beside `book_v2.pdf`. Comparison is blocked on a title prefix so a large library stays tractable. - `text` — overlapping extracted text from the FTS index, for the same book scanned twice or a PDF beside a CBZ of one scan. Only computed for pairs a cheaper signal already flagged. Maps additionally get a `grid` signal, pairing `Tavern_grid.png` with `Tavern_nogrid.png` by filename marker and comparable file size. **Dismissals persist across rescans**, and are remembered pair-wise rather than per group: dismissing {A,B} keeps that pair suppressed even when a later scan finds a third copy C, while C still surfaces against both. A dismissal is dropped automatically once any of its members is deleted. **Search accuracy.** `POST /duplicates/scan` takes an `accuracy` of `exact`, `high`, `medium` (default), or `low`. `exact` runs only the byte-identical pass — an indexed lookup with no file reads and no false positives; the looser levels progressively lower the title and text-overlap cutoffs, taking longer and returning matches that need judging. `exact` disables the fuzzy signals outright rather than running them with an impossible threshold, which is what makes it fast rather than merely strict. **Cross-system matching.** A title match between two different game systems is discounted, and suppressed entirely when either file is under 10 pages: generic handouts ("Character Sheet.pdf") exist once per system and are not copies of one another. A missing `game_system_id` is not treated as "a different system" — maps and tokens are routinely system-agnostic. Byte-identical matches are unaffected: the same bytes are the same file wherever they are filed. **Group edges.** A group's `edges` list the pairwise matches it was built from (`{a, b, reason, score}`). Clustering is transitive, so a group is *not* a set of mutual duplicates: if D resembles A, B, and C while the only real duplicate is A–B, all four land in one group. A client merging a whole group should let the user leave a member out rather than assume every member belongs. Dismissing a pair removes the matching edge from the live results — and with it the transitive path that would otherwise put the rejected pair back on screen. #### Merging `POST /duplicates/merge` folds copies into one in a single transaction (issue #589): ```json { "resource_type": "book", "keep_id": "…", "merge_ids": ["…", "…"], "fields": "fill-empty", "delete_files": false, "dry_run": false } ``` - **`fields`** - `"fill-empty"` (default) copies a value onto the keeper only where it is blank, taking the first merged copy that has one; `"none"` copies nothing; a list of field names copies those, overwriting, with the same whitelist `merge-metadata` uses. Tags are unioned either way: their links are references and move with the rest. - **References move to `keep_id`** instead of being purged as a plain delete does: bookmarks, favorites, tag links, campaign resources (keeping their shares), share links, book-scoped access grants, soundboard and playlist entries, a system's `cover_book_id`, and `[[type:ID]]` embeds in campaign descriptions, wiki pages and templates, and session notes. Where the keeper already has the same row it is deduplicated: one favorite per user, one tag link per tag, one campaign resource per campaign (the keeper's row wins and gains the union of both rows' shares), one access grant per user (the wider level wins), one soundboard entry per set. Bookmarks are deduplicated only when identical in every field. - **Variant families** re-home: a merged copy's variants are filed under the keeper, and a keeper that was a variant of a merged copy becomes the main version (`keeper_promoted`). Refused with 409 when the keeper is itself a variant of some other item and the merged copies have variants, since that would stack a third level. - **Files** are deleted only with `delete_files`. On a read-only mount that is refused with 409 before anything changes. A file that still fails to delete is reported in its `files` entry; the merge itself stands. - **`dry_run`** runs the same code and rolls back, so it reports exactly what the merge would do: `fields` that would change (`{field, from_id, value}`), `tags_added`, `references` per kind as `{moved, deduplicated}`, `variants_reparented`, the `files` that would be deleted, and `warnings` - `page_mismatch` when bookmarks move onto a copy with a different page count (with how many point past its last page), `keep_missing` when the keeper's file is missing, `read_only` when the files cannot be deleted. A dry run may omit `keep_id`: `suggested_keep_id` is then used and the rest of `merge_ids` merged into it. A real merge must name its keeper. Every response carries `items` (the keeper first, then the merged copies, as they stood before the merge) and `suggested_keep_id`: present on disk, then most pages, then largest file, then the more complete metadata, then the oldest row. References are not weighed - they move with the merge. At most 20 copies merge at once. **Variants** are how #306 collapses versions. `POST /duplicates/link` points one record at another via `variant_parent_id`, with a `kind` and a free-text `label` ("v1.0.1"). The accepted kinds depend on `resource_type`, because most of them only mean something for one collection — a gridless token or a form-fillable audio track is not a distinction that exists: | `resource_type` | Accepted `kind` values | | --- | --- | | *(any)* | `version`, `other` | | `book` | + `printer-friendly`, `form-fillable`, `spreads`, `single-page`, `black-and-white` | | `map` | + `printer-friendly`, `black-and-white`, `gridded`, `gridless`, `universal-vtt`, `video`, `image` | | `token` | + `black-and-white`, `color-variation` | | `audio` | + `remix`, `slowed`, `sped-up` | `universal-vtt` is a `.dd2vtt`/`.uvtt` export carrying walls and lights; `video` and `image` are the animated and still cuts of one map, a pair in the same way `gridded`/`gridless` are. A kind outside its collection's list is rejected — as a per-child `errors` entry on `link` (the same skip-and-continue contract as a bad id), and as a `400` on `promote`, whose `kind` describes the copy being demoted. The one exception is a record that *already* carries such a kind, from before the vocabulary was scoped per collection: re-saving it unchanged is allowed, so that one stuck field does not block every other edit to the row. It can be corrected, but not copied to a second record. The main version can describe itself the same way (issue #556): a map pack's main copy is often the gridless cut, and "Main version" hides that. The parent's own `variant_kind`/`variant_label` name it, and detail responses expose them as `variant_main_kind`/`variant_main_label`. `POST /duplicates/describe` sets them - and works on a variant too, without naming its parent. A main version may have an empty kind (that is "Main version"); a variant may not, and an unknown kind is a `400`, a missing id a `404`. `link` takes optional `parent_kind` and `parent_label` to name the parent in the same request; either left out keeps what the parent already has, they are only applied when at least one child linked, and a bad `parent_kind` is reported as an `errors` entry for the parent rather than failing the links. The kind describes the file rather than its position, so a variant promoted to main - by `promote`, or by `reparent_to` on a delete - keeps its kind and label as the main's description. For a two-way `compare`, each item also carries `suggested_kind` and `suggested_label`: the scan's filename-based guess at what that copy is relative to the other (`Tavern_nogrid.png` → `gridless`, `Tavern_grid.png` → `gridded`). `other` means the names did not tell them apart; both are empty for a wider compare, since the guess is relational. Only two levels are allowed — a variant cannot itself have variants — which makes cycles impossible and keeps the version picker flat. Deleting a record that has variants requires `reparent_to`: either an id naming which variant inherits the rest, or `""` to promote them all. A variant is never left pointing at a deleted parent. `POST /duplicates/promote` re-elects the main version of a family that already exists. It is separate from `link` because `link` refuses to file a parent under something else — the two-level rule — which would otherwise leave a user stuck with whichever copy they happened to review first: link the printable cut under the form-fillable one, then meet the lined edition you actually consider the original, and there is no way to say so. Promote moves the whole family at once: `new_parent_id` becomes the main version, `old_parent_id` becomes one of its variants carrying the given `kind`/`label`, and the old parent's children re-home onto the new parent rather than dangling one level too deep. It returns `moved`, the number of rows re-homed including the demoted parent. The new parent may already be a variant of the old one (the common case — promoting a copy you linked earlier); a new parent belonging to a *different* family is a 409. The mirror-image 409 is an `old_parent_id` that is itself a variant: demoting it here would stack a third level, and the fix is to promote over the family it belongs to instead. So `compare` names that family. Each item carries `variant_main` — `null` unless the item is a variant of something else (a parent, or a record whose parent id no longer resolves, reports `null`), otherwise the `id`, `filename`, `relative_path`, `title`, and `variant_count` of its main version. `variant_count` includes the compared item itself, since it is one of the rows the promote would move. Feeding that `id` back as `old_parent_id` is the request the refusal was describing. **Merge whitelist:** `merge-metadata` accepts only descriptive fields. Anything identifying the file — `filepath`, `filename`, `relative_path`, `content_hash`, `file_size`, `game_system_id`, and the `variant_*` columns — is rejected with a 400. `tags` merges additively; it never removes a tag from the record being kept. ### Maintenance *(admin only)* | Endpoint | Method | Description | |----------|--------|-------------| | `/api/maintenance/cleanup-missing` | POST | Remove DB records for files no longer present on disk. Also collects rows that outlived what they described: folder-tag rows whose directory is gone, tag links whose resource is gone, and tags left with no links at all. Returns a per-collection count plus `systems`, `folders`, and `tag_links`. Tagged `library`, so an [API key](#api-keys) needs `library: Read and write` to run it; the sidecar endpoints below are under `maintenance`. | | `/api/maintenance/sidecars/settings` | GET | Read metadata sidecar export settings | | `/api/maintenance/sidecars/settings` | PUT | Configure sidecar export (`formats`, `covers`, `overwrite_foreign`) | | `/api/maintenance/sidecars/export` | POST | Write metadata sidecars for the whole library | Sidecar export (issue #300) writes Grimoire's curated metadata next to the content files so the library folder is self-describing. It is **off by default**: `formats` is empty until an admin enables at least one of `opf`, `nfo`, `json`, or `yaml`, and `POST /sidecars/export` returns 400 while it is disabled. The backfill is additive: it writes only the sidecars that are missing and leaves existing files alone, so re-running it is safe. Books newly indexed by a library scan also get their sidecars written automatically while export is enabled. The export response reports what the run did rather than failing on the first problem - `written`, `skipped_foreign` (files Grimoire did not write and so will not replace), `skipped_missing`, `failed`, `covers`, `read_only`, and a bounded `errors` list. A read-only library mount sets `read_only: true` with an actionable message instead of raising. See [`sidecars.md`](sidecars.md) for the per-format field mapping and how export interacts with sidecar import. ### Backups *(admin only)* | Endpoint | Method | Description | |----------|--------|-------------| | `/api/backups` | GET | List backups, newest first, with `created_at`, `size_bytes`, and `version` | | `/api/backups` | POST | Create a backup now | | `/api/backups/settings` | GET | Read backup schedule, retention, and storage location | | `/api/backups/settings` | PUT | Configure schedule, retention, and storage location | | `/api/backups/{backup_id}/download` | GET | Download a backup archive (`application/zip`) | | `/api/backups/{backup_id}` | DELETE | Delete a backup archive (`204`) | A backup is a single timestamped `.zip` named `grimoire-backup-<UTC timestamp>.zip`, holding a consistent snapshot of the SQLite database (taken with SQLite's online backup API, not a file copy) plus the user-authored directories under `DATA_PATH`: `campaign_uploads/`, `system_covers/`, `audio_covers/`, and `branding/`. A `details.json` manifest records the app version, timestamp, and trigger. The **library is never included**, nor are the regenerable caches (`thumbnails/`, `page_cache/`). The `backup_id` is the timestamp portion of the filename (e.g. `20260821T140355Z`) and is matched against a strict pattern before it reaches the filesystem, so it cannot be used to read a file outside the backup directory. `GET /api/backups` is what makes a check-before-destructive-operation flow possible: a client can read the newest `created_at`, decide whether it is stale, and `POST` a fresh backup before running a destructive rescan or cleanup. `version` comes from the archive's manifest (`"unknown"` if it is missing or unreadable), which makes a cross-version restore detectable. Creating a backup pauses database writes for the length of the snapshot, and a second concurrent create returns `409`. **There is no restore endpoint, by design** - restoring replaces the live database and is done by hand with the server stopped. See [`restore-from-backup.md`](../restore-from-backup.md). Backup settings are `backup_schedule` (`off` | `hourly` | `daily` | `weekly`), `backup_schedule_hour` / `_minute` / `_weekday` (UTC), `backup_retention_count`, `backup_retention_gb`, and `backup_dir`. Retention limits apply independently - oldest-first pruning runs once *either* is exceeded, always leaving at least one backup - and pruning happens after a new archive is written, so the ceiling can be briefly exceeded. Each setting can be pinned by an environment variable (`BACKUP_SCHEDULE`, `BACKUP_RETENTION_COUNT`, `BACKUP_RETENTION_GB`, `BACKUP_DIR`); when pinned, the response flags it via `*_env_locked` and a `PUT` touching it returns `400`. ### Files *(admin only)* Structural file management for the library (issue #302). Every path is relative to the library root, forward-slashed, and validated against path traversal - requests resolving outside the library root are rejected with `403`, and a path with a file or folder name over 255 bytes (longer than any filesystem allows), or one the filesystem cannot look up, with `400`. These endpoints **write to the library**, so they require the library volume to be mounted read-write; a read-only mount returns `409` with an actionable message rather than failing with a server error. Moves and renames relink the existing DB record in place: the record `id` never changes, so tags, favorites, reading progress, bookmarks, campaign links, and FTS entries all survive. A book's `game_system_id` and `category` are re-derived from the destination path, and path-keyed caches (thumbnails, rendered pages, FTS rows) are re-homed or invalidated so no item silently loses its cover. | Endpoint | Method | Description | |----------|--------|-------------| | `/api/files/browse` | GET | List one library folder, merged with each file's indexing state | | `/api/files/move` | POST | Move files/folders into a destination folder, relinking records | | `/api/files/rename` | POST | Rename a file or folder on disk, relinking records | | `/api/files/folder` | POST | Create a folder, writing container/NSFW/frame marker files | | `/api/files/folder/markers` | PUT | Set or clear a folder's container-kind, NSFW, and frame markers | | `/api/files/folder` | DELETE | Delete a folder, recursively when confirmed by name | | `/api/files/folder/contents` | GET | Report whether a folder holds content | | `/api/files/delete` | POST | Remove a file or folder from the index, or also from disk | | `/api/files/folder/scaffold` | POST | Create the standard category folders in a system folder | | `/api/files/upload` | POST | Upload a single file into a library folder | **`GET /api/files/browse`** - `?path=` (omit for the library root), `?limit=` (default and maximum 2000). Metadata sidecars (`<stem>.opf`, `.nfo`, `.grimoire.json`, `.grimoire.yaml`, `.cover.jpg`) are omitted from `entries` and from `total` when a content file with the same stem sits beside them; an orphaned one is listed normally. They are moved and re-stemmed automatically by `/api/files/move` and `/api/files/rename`. Returns `{path, parent, writable, category_host, entries[], total, truncated}`. Each entry carries `name`, `path`, `is_dir`, and `size`; folders add `container_kind`, `nsfw`, `frames_container`, `child_count`, `category_host`, `accepts_container_kind`, and `accepts_frames_marker`, while files add `record_id`, `title`, `collection`, `has_thumbnail`, and `is_missing` when Grimoire has indexed them. A folder directly under `books/` that maps to a game system also carries `record_id`, `title`, and `collection: "system"`, so a client can offer the system editor on it. `category_host` is true when standard category folders belong *inside* that folder - a system folder under `books/`, reached from `books/` through nothing but containers. It is false for `books/` itself, for every container (whose children are the system folders), for a category folder, and outside `books/`, so a client can offer the category scaffold only where it applies. The top-level `category_host` answers the same question about the folder *being browsed* rather than about its children, so a client anchored inside a system folder - where there is no row to hang the action off - can still offer the scaffold. Note `collection` names the *library folder* (`books`, `maps`, …) for files but the resource type (`system`) for system folders. `frames_container` is true for a folder holding a `.frames-container` marker, whose images the token editor offers as overlay art; it is reported separately from `container_kind` because it says nothing about how the folder's children relate to each other, and it applies at any depth under `tokens/` (`tokens/Fantasy Frames` and `tokens/Cyberpunk/Frames` alike). Marker/dotfiles are surfaced as folder properties, never as listable entries. `accepts_container_kind` and `accepts_frames_marker` report whether each declaration would *mean* anything on that folder, so a client offers it only where the scanner reads it - the same rules the write endpoints enforce. The top-level `children_accept_container_kind` and `children_accept_frames_marker` ask the question about a folder created *inside* the one being browsed, for a "new folder" action anchored on it that has no row to read the flags from. An entry whose name on disk is not valid UTF-8 carries `unreadable_name: true`. JSON cannot hold the real name, so its `name` and `path` show `U+FFFD` (`�`) in place of each bad byte. That path names nothing on disk, so the other endpoints treat it as a path that does not exist. Clients should list the entry but offer no actions on it. Nothing else is looked up for it: the scanner skips such names, so there is no record, system, or marker to report. See the [FAQ](faq.md#a-folder-is-skipped-by-the-scan-and-the-file-manager-shows--in-its-name). The listing is **one folder's immediate children only** - it does not recurse - and is bounded: `total` is the folder's true entry count and `truncated` says whether `entries` is a prefix of it, so a client can report what it is hiding instead of presenting a partial folder as complete. `child_count` is capped (counting stops at 1000) because an exact count of a very large folder costs a full directory walk per row; it is `null` when the folder cannot be read. **`POST /api/files/move`** - `{sources: [path], destination: path, on_conflict}`. `on_conflict` is `skip` (default - report the collision and leave the file) or `rename` (land it under a suffixed name). Neither ever overwrites an existing file. Per-item failures are collected rather than aborting the batch: the response is `{moved: [...], skipped: [{path, reason, code}], count}`. Moving a folder relinks every record beneath it. **`POST /api/files/rename`** - `{path, new_name}`. `new_name` is a bare name, not a path. This renames the file on disk, distinct from editing an item's display title. Returns `{from, to, records}`. **`POST /api/files/folder`** - `{parent, name, container_kind, nsfw, frames_container}`. `container_kind` is one of `parent`, `one-page`, `agnostic`, `family`, `publisher`, `generic`, or `""` for a plain folder; it writes the corresponding marker file (`.parent-system-container`, `.one-page-container`, `.system-agnostic-container`, `.system-family-container`, `.publisher-container`, `.container`), `nsfw` writes `.nsfw`, and `frames_container` writes `.frames-container`. Each marker is only accepted where it is actually read, so a request cannot write one the scanner will ignore. A `container_kind` needs a folder inside `books/` standing where a game system belongs - a direct child of `books/`, or one reached from it through nothing but containers; asking for one on a category folder, or anywhere outside `books/`, is rejected with `400`. `frames_container` needs any folder under `tokens/` (at any depth, but not `tokens/` itself) and is rejected with `400` elsewhere. `one-page` and `agnostic` are **singletons**: they name *the* collection of their sort, so a request to give a second folder a kind another folder already holds is rejected with `409`. `GET /api/files/browse` reports which are claimed in `singletons_taken` (`{kind: path}`) so a client can offer only the kinds still available. A folder merely *named* by the reserved convention (`one-page-rpgs`, `system-agnostic`, …) counts as the incumbent. **`PUT /api/files/folder/markers`** - `{path, container_kind?, nsfw?, frames_container?}`. Omitted fields are left untouched. Container kinds are mutually exclusive: setting one clears the others. `frames_container` is an independent axis rather than a seventh kind - it says what the images in a `tokens/` folder are for, not how a `books/` folder's children relate - so toggling it never disturbs `container_kind`. The same placement rules as `POST /api/files/folder` apply to *setting* a marker. *Clearing* one is always allowed, whatever the folder: a marker created by hand in a place the scanner ignores stays removable through this endpoint. **`DELETE /api/files/folder`** - `{path, confirm_name?}`. Always deletes from disk, with the same guards as the permanent mode of `POST /api/files/delete` described below. **`POST /api/files/delete`** - `{path, confirm_name?, delete_files?}`. Two operations behind one route, chosen by `delete_files` (default `false`). Returns `{path, records, files, files_deleted}`: how many indexed rows were removed, how many files went with them, and whether anything was unlinked at all. **Soft (`delete_files: false`, the default) - reversible.** The indexed record is removed, along with every tag, favorite, bookmark, reading-progress entry, and campaign link keyed to its id, but **nothing on disk is touched**. A rescan re-adds whatever is still present and not excluded, which is the point: it is the tool for clearing an entry after adding a `.grimoireignore`, or for dropping one stale row whose file was deleted outside Grimoire, without a full DB cleanup. Because no file is written, this mode deliberately relaxes two constraints the permanent mode enforces. The path **need not still exist** - a row pointing at a missing file is exactly what it is often used to clear - and the library **need not be writable**, so it works on a read-only mount. `files` is `0` and `files_deleted` is `false`; the record count is in `records`. The typed-name guard does not apply: a rescan undoes this, so spending the guard here would cheapen it where it matters. **Permanent (`delete_files: true`) - irreversible, and guarded in proportion to the blast radius.** The file is unlinked rather than moved to a trash folder, and its record is deleted with it, taking every reference above. A file's sidecars (`.opf`, `.nfo`, `.grimoire.yaml`, exported cover) go too, since they describe a file that no longer exists. The path must exist (`404` otherwise) and the library must be writable (`read_only` otherwise). The guard scales rather than being uniform, because a uniform guard trains people to click through it: * A **file** deletes on request. * A folder holding nothing but marker files and **empty descendants** deletes on request. "Empty" is recursive on purpose - a shell of empty shells holds nothing a user would miss, and making them delete it a level at a time is busywork. An *orphaned* sidecar, whose content is already gone, counts as content: it stays visible and manageable rather than being swept up. * A folder that still holds **content** is refused with `428` and `confirm_required` until `confirm_name` matches the folder's own name exactly. Collection roots (`books/`, `maps/`, …) and the library root are always refused with `403`, in **both** modes: forgetting a whole collection is not made reasonable by being reversible. **`GET /api/files/folder/contents`** - `?path=`. Returns `{path, name, has_content}`. Asked before opening a delete dialog so the UI knows which guard applies. Answered server-side deliberately: the browse listing hides sidecars and marker files, so a client counting rows would disagree with the check the delete itself performs. **`POST /api/files/upload`** - multipart form: `file`, `destination`, `relative_dir` (optional), `on_conflict` (`rename` (default), `skip`, or `replace`). Returns `{path, name, size, record_id, replaced}`; `record_id` is `null` and `replaced` is `false` unless the upload replaced a book. Any other `on_conflict` value is a `400`. **One file per request, by design.** A batch endpoint would make a 200-file import succeed or fail as a unit, leaving no way to say which files landed or to retry just the ones that did not. Sending files individually lets a client report per-file progress and retry failures in isolation. The file is streamed to disk in chunks rather than buffered in memory, written under a temporary name, and renamed into place only once complete - so an interrupted upload never leaves a truncated file for the scanner to index. Validation is per collection: only extensions the destination tree actually indexes are accepted (documents/images/archives under `books/`, images, animated maps (`.webm`/`.mp4`), Universal VTT data (`.uvtt`/`.dd2vtt`) and archives under `maps/`, images and archives under `tokens/`, audio under `audio/`), because a file the scanner ignores is an upload that silently does nothing. Uploads into the library root are refused. The supplied filename is reduced to its final component, so a path smuggled through the multipart body cannot escape the destination, and hidden files are rejected outright - a dotfile upload could otherwise write a container marker and reclassify a shelf. Single files are capped at 8 GB (`413` past that), and an upload never overwrites unless asked to with `on_conflict=replace`: by default a name clash is suffixed (`rename`), and `skip` refuses it with `409`. `relative_dir` carries the sub-path from a folder upload (the browser's `webkitRelativePath` minus the file name) so a dropped folder keeps its structure; it is validated against the library root like any other path. **Replacing a book's file (`on_conflict=replace`).** Overwrites the file of a book that is already indexed and keeps its record, so the same id keeps its tags, metadata edits, variant links, favorites, bookmarks, and campaign links. Use it for a corrected file: a better scan, a re-OCRed text layer, an errata printing. - The upload must land on the exact path of an indexed book under `books/` (`destination` + `relative_dir` + the file's name). Anything else - a loose file, a name with nothing there, a map or token - is refused with `409`, so a mistyped name never adds a second copy instead. A `relative_dir` that does not exist is a `404`. - Refused with `409` while a library scan or an OCR run (`POST /api/books/:id/reindex`) is in progress, and while the background re-index queue is working on this same book. The OCR worker resumes from a page checkpoint, and changing the file under it would mix the two files' text. The queue working on *other* books - earlier replaces, or single-book rescans (`POST /api/books/:id/rescan`) - does not block a replace, so a batch of replaces can be sent back to back: `GET /api/scan-status` reports `kind: "reindex"` while it runs. - The new bytes are written under a temporary name and swapped in with one rename, so a failed or aborted upload leaves the original file intact. - Everything read from the old file is rebuilt, because the new one may differ by a word or by every page. Before the response returns: the content hash, size, and mtime, page count, and cover thumbnail are re-read, and the cached page renders are dropped. Page URLs carry the content hash, so clients stop using the old renders. A missing book is marked present again. - For a format with searchable text (PDF, EPUB, DjVu, `.txt`/`.md`/`.rtf`), the old search rows and the OCR checkpoint are cleared and the book is queued for a background re-index: its text is re-read from the text layer if the new file has one, or it is queued for OCR if it is image-only. One background run works through the queue, including books replaced while it runs, and shows its progress on `GET /api/scan-status` (`kind: "reindex"`; `to_index` grows as replaces arrive). If that run cannot start, the next scan indexes the book. The book's `ocr_dpi` override is kept. - **Batch replaces.** Each replace starts or joins that background re-index, so a script replacing many books just sends them one after another. It only has to wait on a `409`: a library scan or other job (poll `GET /api/scan-status` until `running` is `false`), or the re-index reading that very book (retry it after the others). To know when every replaced book is searchable, poll `GET /api/scan-status` after the last upload until `running` is `false`. - Nothing you wrote is touched: title, description, authors, tags, and the rest of the metadata stay as they are, and sidecar `.opf` metadata is not re-applied. Bookmarks keep their page numbers, so a bookmark can point at a different passage, or past the end, if the new file's pages shifted. - If the re-read fails, the response still reports the saved file. The next scan sees the changed size and mtime and rebuilds the book, as it does for any file replaced on disk. **`POST /api/files/folder/scaffold`** - `{path}`. Creates the standard category folders (`Core`, `Supplements`, `Adventures`, `Character Sheets`, `Maps`, `Handouts`, `Homebrew`, `Starter Sets`, `Magazines`) inside a system folder. Each name is chosen to infer back to its canonical category slug, so the folders classify correctly on the next scan. Only valid for a folder that hosts categories - the `category_host` folders above: under `books/`, and not a container, since a container holds systems rather than categories. A container's children *are* the system folders, so they take the scaffold however deeply the containers nest. Returns `{path, created, existing}`. A category is skipped when an existing folder already *resolves to* it, matched on the inferred category rather than the folder name - a system holding `Rules` will not gain a second `Core`, and one holding `Modules` will not gain `Adventures`. `existing` reports the incumbent folder's real name, so running it on a partly-organised system fills only the genuine gaps. ### Filename rules *(admin only)* What a library's own naming conventions mean to Grimoire (issues #521, #522, #569). See [Filename rules](filename-rules.md) for the pattern syntax. Folder patterns sit under the `files` API-key permission, variant rules under `duplicates`. | Endpoint | Method | Description | |----------|--------|-------------| | `/api/naming/folders` | GET | Every folder with a naming pattern, and the fields a pattern may use | | `/api/naming/folders` | PUT | Set a folder's naming pattern, replacing any it had | | `/api/naming/folders` | DELETE | Remove a folder's naming pattern. `?path=` | | `/api/naming/preview` | POST | What a pattern would do to the books it governs. Changes nothing | | `/api/naming/apply-titles` | POST | Set the selected books' titles from their filenames | | `/api/naming/rename` | POST | Rename the selected books' files onto the folder's pattern | | `/api/naming/variant-rules` | GET | The library's own variant filename rules, in the order they are checked | | `/api/naming/variant-rules` | PUT | Replace the whole list of variant filename rules | **`GET /api/naming/folders`** - Returns `{rules[], fields[], read_fields[]}`. Each rule is `{path, pattern, exists, renderable, error}`: `exists` is false when the folder is gone from disk, `renderable` is false when the pattern holds `{*}` (it can read filenames but not write them), and `error` is set when a stored pattern no longer parses - the scanner skips such a rule. `fields` lists every placeholder; `read_fields` the ones reading a filename can set on a book (`title`, `publisher`, `authors`, `year`, `version`, `isbn`, `product_code`). **`PUT /api/naming/folders`** - `{path, pattern}`. `path` is library-relative and must be `books` or a folder under it that exists (`400` outside `books/`, `404` for a missing folder). The pattern must contain `{title}` and at most 20 placeholders (`{*}` included); a pattern that does not parse is `400` with a message naming the problem. Returns the saved rule. A rule covers every book beneath its folder; the nearest folder with a rule wins. New books read their title, and any blank `publisher`, `authors`, `year`, `version`, `isbn` or `product_code`, from their filename with it when they are first indexed - a sidecar `.opf` still wins. Renaming, moving or deleting the folder in the file manager carries or drops its rule. **`POST /api/naming/preview`** - `{path, pattern?}`. Previews `pattern` when given (so an editor can show an unsaved pattern's effect), otherwise the folder's saved one. Returns `{path, pattern, renderable, writable, total, truncated, rows[]}`, with at most 1000 rows. Each row is `{id, filename, relative_path, title, read, title_status, proposed, rename_status, missing[], path_length, long_path, reads_back}`: - `read` - the fields the pattern reads from the filename, or null when the name does not fit - `title_status` - `change`, `same`, or `no_match` - `proposed` - the filename the pattern writes from the book's metadata - `rename_status` - `ok`, `same` (already named so), `missing` (a required field is empty - see `missing`), `too_long` (over 255 bytes), `conflict` (the name is taken on disk, or by an earlier row in the same preview), or `unavailable` (the pattern holds `{*}`) - `long_path` - the full path would exceed 259 characters, the Windows limit; a warning only - `reads_back` - false when the proposed name would not read back to the same title, usually because the title holds the pattern's own separator **`POST /api/naming/apply-titles`** - `{path, ids[]}` (at most 1000). Uses the folder's *saved* pattern. Overwrites each selected book's title with the one its filename carries, and fills the other read fields only where the book has none. Returns `{updated[], skipped: [{id, reason}]}`, where `reason` is `no_match` or `same`. Books outside the rule's reach (under a nearer rule, or elsewhere) are ignored. **`POST /api/naming/rename`** - `{path, ids[]}` (at most 1000). Uses the folder's saved pattern, which must not contain `{*}` (`400`). Each file is renamed through the same relink as `/api/files/rename` and committed on its own, so the record keeps its id, tags, progress and sidecars, and one failure does not undo the rest. Destinations are recomputed server-side - the client never names them. Returns `{renamed: [{id, from, to}], skipped: [{id, reason, detail}]}`, where `reason` is a `rename_status` from the preview or a file-manager error code (`read_only`, `conflict`, `io_error`, ...). **`PUT /api/naming/variant-rules`** - `{rules[]}`, each `{match, value, kind, label, collection}`. `match` is `token` (a whole word, or a run of words, split the way grid markers are), `prefix` or `suffix` of the filename stem, ignoring case. A rule needs a `kind`, a `label`, or both; `kind` must be one the `collection` accepts (any collection's kinds when `collection` is null). At most 200 rules. Rules are checked in order, before the built-in markers, wherever a variant kind is suggested - the duplicate scan and `GET /api/duplicates/compare`. A rule counts only when it matches one copy's filename and not the other's. Returns the saved list; `400` names the first invalid rule. ### Downloads | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/downloads/archive` | GET | any (not guest) | Stream a collection of files as one archive | **Query parameters:** `type` (required) selects the scope, `fmt` selects the format — `zip` (default), `tar`, `tar.gz`, `tar.bz2` — and the remaining parameters depend on the scope: | `type` | Required params | Contents | |--------|-----------------|----------| | `system` | `id` | Every book in a game system, foldered by category | | `system_category` | `id`, `category` | Every book in one category of a system | | `book_folder` | `id`, `folder` | Books in a nested subfolder, sub-hierarchy preserved | | `map_folder` | `folder` | Every map under a maps subfolder | | `token_folder` | `folder` | Every token under a tokens subfolder | | `audio_folder` | `folder` | Every track under an audio subfolder | | `model_folder` | `folder` | Every model under a models subfolder | | `tag` | `tag` | Everything carrying a tag, foldered by resource type | | `tag_type` | `tag`, `resource_type` | One resource type's slice of a tag | | `tag_folder` | `tag`, `resource_type`, `folder` | One tagged folder's contents | | `library_folder` *(admin)* | `folder` | Any library folder **as it sits on disk** | The three `tag` scopes mirror the tag browser's own levels, so a user can take a whole tag, one of its type sections, or one tagged folder without first finding where those files live on disk (issue #401). `tag` is the tag's *internal* key and `folder` is the folder group's `path` exactly as `/api/tags/{internal}/items` returned it — the caller never has to know that book folders are addressed (`{system_id}/{category}/…`) differently from media folders. All three include items reached through a tagged folder as well as directly tagged ones, and an item reachable both ways appears once. Only `tag` groups its contents into a folder per resource type: a tag spans collections, so a map and a token sharing a filename would otherwise overwrite each other. The single-type scopes are already one type and stay flat. Tagged **game systems are excluded** from all three. A tagged system is a whole shelf rather than a file, and expanding it would turn a four-item tag into a multi-gigabyte download; `type=system` remains the way to archive one. For the same reason `tag_type` rejects `resource_type=system` with a 400, and the tag browser shows no download button on a systems section. Every scope except `library_folder` is built from indexed records, so each is filtered by what the caller may see: explicit content is dropped for users with `allow_explicit` off, and books are filtered through the same access rules the browse endpoints use — a bulk archive is the easiest way to walk out with a restricted book. Variants are deliberately **included** (issues #304, #306): a "download this whole system" archive should hold every file the user owns. `library_folder` is the file manager's scope and works differently. It walks the filesystem rather than the index, so the archive holds *everything* under the folder — loose files the scanner ignored, unindexed formats, sidecars — with in-archive paths relative to the requested folder. That is the point: the file manager browses the filesystem, and an archive of only the rows Grimoire happens to have would silently drop files the user can plainly see. Because nothing here resolves through a book row, no per-book access rule can be applied to it, so the scope is **admin-only** — the same audience as the file manager itself. Dotfiles are skipped, symlinks pointing outside the library are dropped, `folder` is validated against traversal (`403`), and the walk is capped at 5000 files / 50 GB (`413` past that, asking for a subfolder instead). Archive entry paths are sanitised for cross-platform extraction: Windows-illegal characters are replaced, leading/trailing dots and spaces stripped, and each component clamped to 255 bytes. Responses stream with a `Content-Disposition: attachment` filename derived from the scope. `404` when the scope resolves to no files. ### Logs *(admin only)* | Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | `/api/logs` | GET | admin | Retrieve recent log entries from the in-memory ring buffer | **Query parameters:** | Parameter | Default | Description | |-----------|---------|-------------| | `level` | `info` | Minimum log level: `debug`, `info`, `warning`, `error`, `critical`. Follows standard hierarchy - `debug` returns all levels, `info` returns info and above, etc. | | `limit` | `200` | Max entries to return (1–20000) | | `offset` | `0` | Skip this many of the most-recent matching entries (historical pagination, ignored when `after_seq` is set) | | `after_seq` | - | Return only entries with `seq` greater than this value. Use the `max_seq` from the previous response as a cursor for live polling. Exact even when hundreds of entries arrive between polls. | | `user` | - | Return only lines emitted by this user's requests (exact username). `total` counts the filtered lines. | **Response:** ```json { "entries": [ { "seq": 142, "timestamp": "2026-04-10T12:34:56.789Z", "level": "INFO", "logger": "grimoire.activity", "message": "Signed in (password)", "user": "alice" } ], "total": 42, "max_seq": 142, "level": "info", "limit": 200, "offset": 0, "user": null, "users": ["alice", "bob"] } ``` Each entry's `user` is the username whose request emitted the line - `null` for lines from startup, the library scanner, background jobs, or a request that had not yet authenticated. `users` lists every username with a line in the buffer, for a user picker. > **Note:** Console/Docker log verbosity is controlled by the `LOG_LEVEL` environment variable (default `info`). The `/api/logs` endpoint always has access to `DEBUG`-level entries regardless of `LOG_LEVEL`, because the in-memory buffer captures all levels. The buffer holds up to 20 000 entries. **Cleanup response:** ```json { "removed": { "books": 2, "maps": 0, "tokens": 1, "audio": 0, "systems": 0 } } ``` --- ## Error responses All errors follow FastAPI's standard format: ```json {"detail": "Human-readable error message"} ``` | Status | Meaning | |--------|---------| | `400` | Bad request - validation failed or business rule violated | | `401` | Not authenticated - missing or invalid token, or an unknown or expired API key | | `403` | Forbidden - insufficient role, not a campaign member, or an API key without the needed permission | | `404` | Resource not found | | `409` | Conflict - duplicate (e.g. duplicate username, resource already linked) | | `422` | Unprocessable entity - request body failed schema validation | | `429` | Too many requests - see [Rate limiting](#rate-limiting) | --- ## Library directory structure The scanner expects files organized as: ``` /library/ books/ {System Name}/ core/ → category: core supplement/ → category: supplement adventure/ → category: adventure character-sheet/ → category: character-sheet map/ → category: map handout/ → category: handout homebrew/ → category: homebrew {custom name}/ → category: slugified folder name maps/ {any folder structure}/ tokens/ {any folder structure}/ ``` Game system records are created automatically from the folder names under `books/`. Append `(nsfw)` to a system folder name to mark all its content as explicit. ### OPF sidecar metadata When a book is first indexed, the scanner looks for an OPF metadata file alongside the PDF. Two locations are checked in order: 1. `<bookname>.opf` - same directory, same stem as the PDF. 2. `metadata.opf` - same directory (Calibre's per-book-folder format). Fields read: `dc:title`, `dc:creator` (role=aut → authors), `dc:publisher`, `dc:date` (year), `dc:description` (HTML stripped), `dc:subject` (→ tags). Cover images referenced in the OPF `<guide>` are excluded from the book list automatically. By default OPF metadata is applied only on a book's first scan, and ordinary rescans (`metadata_mode: "new"`) do not overwrite values edited via the API. To re-apply sidecar metadata to already-indexed books, rescan with `metadata_mode: "missing"` (fills empty fields only, non-destructive) or `metadata_mode: "replace"` (overwrites with the sidecar's values). Combine with `scope` to refresh just one folder.