# API Volumen serves a JSON API under `/api/volumen`; the admin interface at `/admin` is server-rendered HTML for humans and is not part of this API. ## HTTP API Base URL: `{base_url}/api/volumen`, where `base_url` is the `[site].base_url` setting ([CONFIGURATION.md](CONFIGURATION.md)). Every path below is relative to that prefix. Reads need no credential. A write carries `Authorization: Bearer `, where the token is minted in the admin at **Settings, API tokens** and shown once, at creation. Two scopes exist: `write` for `POST` and `PUT`, and `delete` for `DELETE`. A token created with no scope selected is unrestricted, and a scope list that names no recognised scope is refused rather than turned into an unrestricted token. There is no read scope, because every read endpoint is public, so a token can only widen access to the write and delete operations. A `HEAD` request is answered as the `GET` of the same path: same status, same headers, no body. | Method | Path | Purpose | |---|---|---| | `GET` | `/api/volumen/site` | Site metadata | | `GET` | `/api/volumen/posts` | Paginated, filterable post list | | `GET` | `/api/volumen/posts/batch` | Several post details in one request | | `GET` | `/api/volumen/posts/{slug}` | One post with body and rendered HTML | | `POST` | `/api/volumen/posts` | Create a post (scope `write`) | | `PUT` | `/api/volumen/posts/{slug}` | Update a post (scope `write`) | | `DELETE` | `/api/volumen/posts/{slug}` | Delete a post (scope `delete`) | | `GET` | `/api/volumen/tags` | Tag cloud with counts | | `GET` | `/api/volumen/tags/{tag}` | Posts carrying one tag | | `GET` | `/api/volumen/tags/{tag}/feed.xml` | RSS 2.0 feed for one tag | | `GET` | `/api/volumen/tags/{tag}/feed.atom` | Atom 1.0 feed for one tag | | `GET` | `/api/volumen/tags/{tag}/feed.json` | JSON Feed 1.1 for one tag | | `GET` | `/api/volumen/series` | Series list with post counts | | `GET` | `/api/volumen/series/{name}` | Posts of one series, in reading order | | `GET` | `/api/volumen/series/{name}/feed.xml` | RSS 2.0 feed for one series | | `GET` | `/api/volumen/series/{name}/feed.atom` | Atom 1.0 feed for one series | | `GET` | `/api/volumen/series/{name}/feed.json` | JSON Feed 1.1 for one series | | `GET` | `/api/volumen/feed.xml` | RSS 2.0 feed | | `GET` | `/api/volumen/feed.atom` | Atom 1.0 feed | | `GET` | `/api/volumen/feed.json` | JSON Feed 1.1 document | | `GET` | `/api/volumen/sitemap.xml` | XML sitemap | | `OPTIONS` | `/api/volumen/{rest...}` | CORS preflight for the whole subtree | ### `GET /api/volumen/site` Site metadata, taken from the configuration. Every value is echoed as configured and every one of them is a string, so `fediverse_creator` is `""` when the key is unset rather than `null`. ```sh curl -s 'https://lab.example.com/api/volumen/site' ``` ```json { "title": "Research Notes", "description": "Powered by Volumen.", "base_url": "https://lab.example", "language": "en", "author": "Anonymous", "fediverse_creator": "" } ``` The response carries an `ETag`; send it back in `If-None-Match` to receive `304 Not Modified` with no body. The post list, the post batch, the tag cloud, a tag's post list and a single post carry one as well. The feeds, the sitemap and the other endpoints do not. ### `GET /api/volumen/posts` | Parameter | Type | Default | Effect | |---|---|---|---| | `page` | integer | `1` | Page number, `1` to `1000000`. Ignored when `cursor` is set | | `limit` | integer | `20` | Page size, `1` to `100` | | `lang` | string | unset | Keep posts whose language is this value, plus posts flagged `all_langs` | | `tag` | string | unset | Keep posts carrying this tag | | `q` | string | unset | Case-insensitive search over the title, the tags, the excerpt and the body. Matching posts are ordered by relevance: a title hit outranks a tag hit, an excerpt hit and a body hit, and a title that starts with the query leads its class; equal scores keep the date order | | `cursor` | string | unset | Slug to start after; switches the response to the cursor shape | A malformed `page` or `limit` is rejected with `422` and the `validation` envelope. An unknown slug in `cursor` yields a page with no posts and a `null` cursor rather than silently rewinding to the first page, so a paging client never receives posts it already holds. ```sh curl -s 'https://lab.example.com/api/volumen/posts?limit=20' ``` ```json { "page_size": 20, "total": 2, "posts": [ { "slug": "alpha", "title": "Alpha", "excerpt": "Alpha body with a [link](https://example.com).", "date": "2026-08-18", "lang": "cs", "tags": ["go", "research"], "author": "Petr", "fediverse_creator": "@petr@social", "cover": "/media/c.webp", "cover_alt": "alt", "cover_caption": "caption", "reading_time": 1, "translations": { "en": "alpha-en" }, "series": "Series", "series_order": 1, "url": "/api/volumen/posts/alpha" }, { "slug": "beta", "title": "Beta", "excerpt": "Beta body.", "date": "2026-07-01", "lang": "en", "tags": ["go"], "reading_time": 1, "url": "/api/volumen/posts/beta" } ], "page": 1, "has_next": false, "has_prev": false } ``` Without a cursor the response carries `page`, `has_next` and `has_prev`. With a cursor it carries `next_cursor` instead: the slug of the last post on the page, or `null` when the list is exhausted. When two posts share a slug (a post and its translation may) the cursor is `slug.lang`, so the next page resumes after the exact post rather than after the first variant that matches. Either way `total` counts every post the filter matched, before pagination, and `page_size` is the limit the server applied. ```json { "page_size": 1, "total": 2, "posts": [ { "slug": "beta", "title": "Beta", "excerpt": "Beta body.", "date": "2026-07-01", "lang": "en", "tags": ["go"], "reading_time": 1, "url": "/api/volumen/posts/beta" } ], "next_cursor": null } ``` #### Fields Used by the list endpoints, the tag and series lists, the series detail and the write responses. The shape is `payloads.Summary`, built by `payloads.BuildSummary`; the struct tags decide which fields are omitted. | Field | Type | Presence | Notes | |---|---|---|---| | `slug` | string | omitted when empty | Stable post identifier | | `title` | string | omitted when empty | Post title | | `excerpt` | string | always present | The frontmatter excerpt, else the first non-heading body paragraph cut to 200 characters, with an ellipsis when anything was dropped | | `date` | string | omitted when empty | Publication date, `YYYY-MM-DD` | | `lang` | string | omitted when empty | Post language | | `tags` | array of strings | omitted when empty | Post tags | | `author` | string | omitted when empty | Post author from the frontmatter; the site author is not substituted into the payload | | `fediverse_creator` | string | omitted when empty | `@user@host`, per-post override of the site default | | `doi` | string | omitted when empty | Digital Object Identifier, bare `10.…/…` form; a stored `doi.org` URL or `doi:` prefix is normalised for display, and a value that fails the syntax rule passes through untouched. Set in the frontmatter or the admin editor, not by the write endpoints | | `orcid` | string | omitted when empty | The author's ORCID iD, `0000-0000-0000-000X`, check digit verified when the value is written from the admin editor; upper-cased for display when valid, the raw text otherwise. Set in the frontmatter or the admin editor, not by the write endpoints | | `cover` | string | omitted when empty | Cover image path or URL, typically `/media/...` | | `cover_alt` | string | omitted when empty | Alt text for the cover | | `cover_caption` | string | omitted when empty | Caption for the cover | | `reading_time` | integer | always present | Minutes, `ceil(words / 200)`, at least 1 | | `translations` | object | omitted when empty | Maps a language code to the slug of a sibling translation | | `series` | string | omitted when empty | Series name | | `series_order` | integer | omitted when the frontmatter has none | Position within the series | | `url` | string | always present | API detail URL, `/api/volumen/posts/{slug}` | ### `GET /api/volumen/posts/batch` Returns the detail of several posts in one response. `slugs` is a comma-separated list; entries are trimmed, empty ones dropped, and the list is capped at 100 slugs. A slug that names no published post is skipped, so the `posts` array may be shorter than the request and the endpoint never answers `404`. Without `slugs` the `posts` array is empty. ```sh curl -s 'https://lab.example.com/api/volumen/posts/batch?slugs=alpha,beta' ``` The entry below is one detail response, printed in full: ```json { "posts": [ { "slug": "alpha", "title": "Alpha", "excerpt": "Alpha body with a [link](https://example.com).", "date": "2026-08-18", "lang": "cs", "tags": ["go", "research"], "author": "Petr", "fediverse_creator": "@petr@social", "cover": "/media/c.webp", "cover_alt": "alt", "cover_caption": "caption", "reading_time": 1, "translations": { "en": "alpha-en" }, "series": "Series", "series_order": 1, "url": "/api/volumen/posts/alpha", "body": "Alpha **body** with a [link](https://example.com).\n", "html": "

Alpha body with a link.

\n", "toc": "
\n\n
\n", "meta": { "url": "https://lab.example/alpha", "json_ld": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://lab.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://lab.example/alpha\"}", "og": { "article:author": "Petr", "article:published_time": "2026-08-18", "article:tag": ["go", "research"], "og:description": "Alpha body with a [link](https://example.com).", "og:image": "/media/c.webp", "og:locale": "cs", "og:title": "Alpha", "og:type": "article", "og:url": "https://lab.example/alpha" }, "twitter": { "twitter:card": "summary_large_image", "twitter:creator": "@petr@social", "twitter:description": "Alpha body with a [link](https://example.com).", "twitter:image": "/media/c.webp", "twitter:title": "Alpha" } } } ] } ``` The JSON here is reformatted for the page; the response shape is pinned by the fixtures under [`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/), which carry a differently configured deployment's values. ### `GET /api/volumen/posts/{slug}` | Parameter | Type | Default | Effect | |---|---|---|---| | `lang` | string | unset | Select the language variant; a post flagged `all_langs` matches every value | | `preview_token` | string | unset | Signed preview credential for a draft or a scheduled post | The response is a post detail. A slug that matches an alias is answered with `301 Moved Permanently` and a `Location` header naming the canonical post. A draft and a scheduled post are distinguishable on purpose: the error code says which of the two withheld the post, because the client already knows the slug and the two states need different handling. The response carries an `ETag` naming the post's state: send it back in `If-None-Match` for a `304`, or in `If-Match` on a write to refuse overwriting a change you never saw. | Status | Meaning | |---|---| | `200` | The post detail | | `301` | The slug is an alias; `Location` names the canonical slug | | `304` | The request carried `If-None-Match` matching the current `ETag` | | `404` | `{"error": "not_found"}`, or `{"error": "draft"}` / `{"error": "scheduled"}` for a withheld post with no valid preview token | | `500` | `{"error": "render_failed"}` when the Markdown pipeline fails | A preview link is signed as an HMAC over the slug and an expiry stamp, keyed with `[admin].session_key`, and stays valid for seven days. Without a session key no link is issued at all: the admin route `GET /admin/posts/{slug}/preview-link` then answers `409` with the code `no_session_key`. A preview response carries `Cache-Control: no-store`: it holds unpublished content, and a shared cache must not keep it. #### Fields Returned by `GET /api/volumen/posts/{slug}` and by the batch endpoint. The shape is `payloads.Detail`: the summary above, plus: | Field | Type | Notes | |---|---|---| | `body` | string | Raw Markdown source | | `html` | string | Rendered, sanitised HTML | | `toc` | string | Sanitised table of contents | | `meta` | object | SEO and discovery metadata, computed per request from the post and never persisted | | `references` | array of objects | The structured bibliography from the post's `refs` frontmatter: each entry has `num`, optional `authors` (`{name, orcid}`), `title`, `venue`, `year`, `volume`, `pages`, `doi`, `arxiv`, `url`, or the verbatim `raw` text. When the entry's DOI is published by another post of the same instance, it also carries `internal`: the API address of that post, so a consumer can keep the reader on site. Omitted when the post cites nothing | | `fields` | object | The frontmatter keys the engine does not consume itself, the author's own: `colour: "#0f0"` lands here as `{"colour": "#0f0"}`. Nested tables, arrays and TOML date-times pass through (dates as their canonical strings). Omitted when every key is a known one | `html` is rendered by scriptorium (CommonMark, GFM, footnotes, definition lists) with `$$…$$` and `$…$` mathematics converted to MathML Core and fenced mermaid blocks (flowcharts and sequence diagrams) drawn as inline SVG, and it is sanitised by bluemonday against a narrow allowlist: headings keep their `id` attributes, fenced code blocks carry `class="language-..."`, an image whose title is set becomes a `figure` with a `figcaption`, a post with `refs` gets a numbered `
` spliced at its `[[refs]]` marker, or appended at the end of the body when it carries none, with every inline `[n]` citation linked to it. A reference whose DOI is published by another post of the same instance links to that post's API address instead of the external resolver, and the same address appears as the `url` of its JSON-LD citation next to the canonical identifier; a post citing its own DOI keeps the resolver link. Every link in the body and in the table of contents gains `rel="noopener noreferrer"`. The allowlist admits `http`, `https` and `mailto` URLs plus relative ones, so a body cannot inject a script, a style or an event handler. A mermaid diagram the renderer does not carry stays a fenced code block, and a mathematics construct outside the mappable surface stays visible as its verbatim source in the place it was written. `toc` is the wrapper `
` with a nested list of heading links. The wrapper is always present, even when the body has no headings, in which case it holds an empty list: ```json "toc": "
\n
    \n
    \n" ``` An empty body has no table of contents at all and yields an empty `toc` string. `meta` carries: | Field | Type | Notes | |---|---|---| | `url` | string | Canonical post URL: `base_url` plus the slug, or the API path when no `base_url` is set | | `json_ld` | string | A Schema.org `Article` document, serialised as a JSON string | | `og` | object | Open Graph fields, keyed `og:type`, `og:title`, `og:description`, `og:url`, `og:locale`, `article:published_time`, plus `og:image`, `article:tag` and `article:author` when the post carries them | | `twitter` | object | Card fields, keyed `twitter:card`, `twitter:title`, `twitter:description`, plus `twitter:image` and `twitter:creator` when the post carries them | ### `POST /api/volumen/posts` Creates a post. The body is a JSON object; fields not listed are ignored. The response is `201 Created` with the post summary as its body, no `Location` header, and an `ETag` naming the new state for a later `If-Match` write. | Field | Type | Effect | |---|---|---| | `title` | string | Post title | | `slug` | string | Post slug; required, and unique | | `lang` | string | Language code | | `author` | string | Author name | | `fediverse_creator` | string | `@user@host` handle | | `excerpt` | string | Summary text, overrides the derived excerpt | | `body` | string | Markdown source, at most 1 MiB | | `tags` | array of strings, or a comma-separated string | Post tags | | `cover` | string | Cover image path or URL | | `cover_alt` | string | Cover alt text | | `cover_caption` | string | Cover caption | | `series` | string | Series name | | `series_order` | integer | Position within the series | | `date` | string | Publication date, `YYYY-MM-DD` | | `publish_at` | string | Scheduled publication date, `YYYY-MM-DD` | | `draft` | boolean | Withhold the post | | `all_langs` | boolean | Serve the post for every requested language | An empty string or `null` clears an optional field. A value of the wrong type is rejected with `400` rather than coerced. Validation failures answer `{"error": "validation", "message": "..."}` with messages such as `Slug is required.`, `Invalid slug.`, `A post with that slug already exists.` and `Body must be at most 1048576 bytes.` ```sh curl -s -X POST 'https://lab.example.com/api/volumen/posts' \ -H 'Authorization: Bearer vol_...' \ -H 'Content-Type: application/json' \ -d '{"slug":"hello","title":"Hello","body":"Hi.","tags":["meta"]}' ``` ### `PUT /api/volumen/posts/{slug}` Updates the post named by the path. Fields omitted from the body keep their stored value, so the request is a merge rather than a replacement. The response is `200 OK` with the post summary. A `slug` in the body that differs from the path renames the post: the file is written under the new slug and the old file is soft-deleted, so the rename stays undoable. The request may carry `If-Match` with the `ETag` a `GET` of the post served: when it no longer names the stored state, the write is refused with `412` instead of overwriting a change the client never saw. `If-Match: *` demands only that the post exists. A write without the header stays unconditional. The `200` response carries the new state's `ETag`, so edits can chain without re-reading. The precondition is checked against the post the path names, the one a `GET` without `lang` serves. | Status | Meaning | |---|---| | `200` | The post summary, with the new `ETag` | | `400` | `{"error": "invalid_json"}` or the `validation` envelope | | `401` | `{"error": "unauthorized"}` | | `403` | `{"error": "forbidden", "message": "Token lacks 'write' scope"}` | | `404` | `{"error": "not_found"}` | | `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state | | `500` | `{"error": "save_failed"}` when the file cannot be written, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders | ### `DELETE /api/volumen/posts/{slug}` Deletes the post. The response is `204 No Content` with no body. The post is soft-deleted as a tombstone, so it can be restored from the admin while the tombstone survives. An `If-Match` header carrying the `ETag` a `GET` of the post served refuses the delete with `412` when it names an older state; `*` and a missing header are unconditional. | Status | Meaning | |---|---| | `204` | Deleted | | `401` | `{"error": "unauthorized"}` | | `403` | `{"error": "forbidden", "message": "Token lacks 'delete' scope"}` | | `404` | `{"error": "not_found"}` | | `412` | `{"error": "precondition_failed"}` when `If-Match` names an older state | | `500` | `{"error": "delete_failed"}`, or `{"error": "render_failed"}` when an `If-Match` precondition has to render a stored post that no longer renders | ### `GET /api/volumen/tags` ```json { "tags": [ { "name": "go", "count": 2 }, { "name": "research", "count": 1 } ] } ``` Tags are ordered by count, most used first, then by name. Only published posts are counted. The response carries an `ETag`. ### `GET /api/volumen/tags/{tag}` A page of the posts carrying one tag, in the shape of [`GET /api/volumen/posts`](#get-apivolumenposts) with the same `page`, `limit`, `lang` and `cursor` parameters. A tag that names no published post answers `404` with `{"error": "not_found"}`. ### `GET /api/volumen/series` ```json { "series": [{ "name": "Series", "count": 1 }] } ``` Ordered by count, most posts first, then by name. ### `GET /api/volumen/series/{name}` ```json { "name": "Series", "count": 1, "posts": [ { "slug": "alpha", "title": "Alpha", "excerpt": "Alpha body with a [link](https://example.com).", "date": "2026-08-18", "lang": "cs", "tags": ["go", "research"], "author": "Petr", "reading_time": 1, "series": "Series", "series_order": 1, "url": "/api/volumen/posts/alpha" } ] } ``` The posts are in reading order: by `series_order` first, and a post without one after every ordered post, then by date and slug. A name that matches no series answers `404` with `{"error": "not_found"}`. ### Feeds | Path | Document | |---|---| | `/api/volumen/feed.xml` | RSS 2.0, `application/rss+xml` | | `/api/volumen/feed.atom` | Atom 1.0, `application/atom+xml` | | `/api/volumen/feed.json` | JSON Feed 1.1, `application/json` | | `/api/volumen/tags/{tag}/feed.xml`, `/api/volumen/tags/{tag}/feed.atom`, `/api/volumen/tags/{tag}/feed.json` | The same three formats for one tag | | `/api/volumen/series/{name}/feed.xml`, `/api/volumen/series/{name}/feed.atom`, `/api/volumen/series/{name}/feed.json` | The same three formats for one series | A feed carries at most 20 posts: the newest 20 for the site feed and a tag feed, and the first 20 of the series, in its reading order, for a series feed. Every document names its own address: RSS carries ``, Atom carries ``, and JSON Feed carries `feed_url`, each with the path that was actually requested, so a tag feed or a series feed reports its own URL rather than the site feed's. A tag or series feed answers `404` with `{"error": "not_found"}` when nothing matches. ### `GET /api/volumen/sitemap.xml` Every published post, truncated to the newest 50 000 (the ceiling the sitemaps.org protocol sets for one document), with `` from the file's modification time and, for a post whose file cannot be read, from its date. The content type is `application/xml`. ### `OPTIONS /api/volumen/{rest...}` The CORS preflight for the whole API subtree. It advertises `GET, POST, PUT, DELETE, OPTIONS` with `Content-Type, Authorization`, sets `Access-Control-Max-Age: 600`, and answers `200` with an empty `text/plain; charset=utf-8` body. The write methods are listed on purpose: a browser preflight for a cross-origin `POST` fails when the answer names only `GET`. The answer carries the write CORS set rather than the read one, so the allowed origin follows the rule the Headers section states for a token-authenticated write. ### Flow The token-authenticated write flow, from the request to the stored file: ```mermaid sequenceDiagram participant Client participant API participant Tokens as Token store participant Posts as Content directory Client->>API: POST /api/volumen/posts with Authorization: Bearer API->>Tokens: Authenticate the raw token Tokens->>Tokens: compare the SHA-256 digest Tokens-->>API: token record with its scopes API->>API: require the write scope API->>Posts: Save the post, archiving the previous version Posts-->>API: the saved path API-->>Client: 201 Created with the post summary ``` At each step, a failure surfaces in the response: a missing or unknown token as `401 unauthorized`, a token without the scope as `403 forbidden`, a body that is not a JSON object as `400 invalid_json`, and a rejected field as `400 validation`. When the post is stored, the `post.created` event fires and every webhook subscribed to it receives the post summary. ## Notes The Go structs and their JSON tags are the authority on the response shapes, and the committed fixtures under [`internal/httpapi/testdata/contract/`](../internal/httpapi/testdata/contract/) pin them: a change to a body or a status code fails the contract test until the fixture is regenerated with `go test ./internal/httpapi -update-contract` and the change is recorded in the changelog. `go doc ./internal/payloads` covers the exported types. This file explains what the surface is for; it does not repeat the struct definitions, because a copy of a signature is a future lie. ### Errors Every failure uses one envelope: an `error` code, optionally with a human-readable `message` and extra fields. ```json { "error": "not_found" } ``` ```json { "error": "validation", "message": "Invalid slug.", "field": "slug" } ``` | Code | Status | Extra fields | Meaning | |---|---|---|---| | `not_found` | 404 | | No such post, tag, series, or media file | | `draft` | 404 | | The post exists but is a draft | | `scheduled` | 404 | | The post exists but is scheduled for the future | | `validation` | 400, 422 | `message`, and `field` for a query parameter | The payload or query is invalid | | `invalid_json` | 400 | | The request body is not a JSON object | | `payload_too_large` | 413 | | The request body exceeds 10 MiB | | `precondition_failed` | 412 | | The request's `If-Match` names an older state than the stored one | | `unauthorized` | 401 | | Missing or invalid bearer token | | `forbidden` | 403 | `message` | The token lacks the required scope | | `rate_limited` | 429 | `retry_after` | Too many requests | | `render_failed` | 500 | | The Markdown pipeline failed | | `save_failed` | 500 | | The post could not be written to disk | | `delete_failed` | 500 | | The post could not be deleted | A `401` carries `WWW-Authenticate: Bearer`. Failures written by the API carry `Cache-Control: no-store`, so a shared cache never keeps one; the `429` is written by the rate limiter before the handler and carries the rate-limit headers instead. The router answers for itself outside the routes above: a path under `/api/volumen/` that matches no route, or a method a route does not serve, gets `405 Method Not Allowed` with an `Allow` header listing the methods the path does serve, and a plain-text body rather than the JSON envelope. ### Headers and caching - JSON responses use `Content-Type: application/json`. The XML documents carry `application/rss+xml`, `application/atom+xml` and `application/xml`. - A read answers with the permissive set: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS` and `Access-Control-Allow-Headers: Content-Type, Authorization`. A token-authenticated write echoes the configured `base_url` as the allowed origin when the request carries an `Origin` header, and falls back to `*` when no base URL is configured or the request carries no `Origin` header, so a browser can read a cross-origin write from the site itself and not from anywhere else. The router's `405` and the rate limiter's `429` are written outside a handler and carry only what their own sections note. - Successful reads carry `Cache-Control: public, max-age=60, stale-while-revalidate=21600`. Write responses carry `no-store`. - Bodies of 500 bytes or more are gzip-compressed when the client sends `Accept-Encoding: gzip`, and such a response carries `Vary: Accept-Encoding` so a shared cache keys on it. - Dates are ISO 8601 (`YYYY-MM-DD`). - Lists are newest first by post date. Drafts and scheduled posts never appear. - Empty optional fields are omitted rather than sent as `null`; a client must treat a missing key as "not set". Two fields of a post summary are the exception and are always present: `excerpt` and `reading_time`. The site payload is different in kind: it echoes the configured values, and every one of them is a string, so its `fediverse_creator` is `""` when the key is unset. - Bodies are written by the Go 1.27 `encoding/json/v2` encoder with deterministic member order and no HTML escaping, so `<`, `>` and `&` appear as themselves and the same content produces the same bytes. That is what makes the `ETag` round trip above reliable. A request body that names the same member twice is rejected as `invalid_json` rather than silently taking one of the two values. - Malformed query parameters are rejected with `422` and the validation envelope. ### Rate limiting With `[api].rate_limit` above zero, every `/api/volumen/*` response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, counted per client address in a sliding window of `[api].rate_limit_window` seconds. A request over the budget is answered `429`: ```json { "error": "rate_limited", "retry_after": 42 } ``` with a `Retry-After` header carrying the same number of seconds. The limiter covers the API prefix only: `/media/*`, `/admin/*` and `/healthz` are not counted. The client address is the last entry of `X-Forwarded-For` when `[server].trust_proxy` is set and the connection comes from a peer listed in `[server].trusted_proxies`, and the connection address otherwise. The refusal carries the read CORS set (`GET, OPTIONS` with `Content-Type`) rather than the wide one, and no cache header. ### Operational routes Outside the API prefix, the application serves a few operational routes: | Route | Behaviour | |---|---| | `GET /healthz` | `{"status": "ok", "checks": {...}}` with `Cache-Control: no-store`. The status is `degraded` and the code `503` when the content directory is missing, a post file cannot be parsed, the users, tokens or templates file cannot be read, or the filesystem has under 100 MB free. `content_dir`, `users_file` and `disk` are always present; `content_files`, `tokens_file` and `templates_file` appear only when something is wrong with them. Not rate limited | | `GET /robots.txt` | Allows all crawlers and points at the sitemap: `Sitemap: {base_url}/api/volumen/sitemap.xml` | | `GET /sitemap.xml` | `301` redirect to `/api/volumen/sitemap.xml` | | `GET /favicon.ico` | The bundled SVG icon, `Cache-Control: public, max-age=86400` | | `GET /media/{path}` | An uploaded image from `/media/`. The file name is a UUID with a `.webp`, `.avif` or `.svg` extension, assigned when the upload is stored, so no uploader chooses a name; only those three extensions resolve, and anything else, including a missing file, answers `404` with `{"error": "not_found"}`. The type is set from the extension rather than sniffed, so a file whose bytes do not match is still served as an image and never as a document; an SVG, the one accepted image that is also a document, is additionally served with a sandboxing `Content-Security-Policy`, so opened at its own URL it runs as no one. Responses carry `Cache-Control: public, max-age=604800`. They are served outside the gzip and session layers, so a large image is never buffered in memory | | `GET /` | `303` redirect to `/admin/` | | anything else outside the API prefix | `404` with `{"error": "not_found"}` |