523 lines
49 KiB
Markdown
523 lines
49 KiB
Markdown
# API reference
|
|
|
|
The full, searchable reference is the Integration page of the running app (`/integration`). It
|
|
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
|
|
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
|
|
[api/articles.md](api/articles.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
|
|
|
## Base URL
|
|
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
|
|
for example `https://cms.example/v1/article/`.
|
|
|
|
## Authentication
|
|
- Public routes need nothing: `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo`
|
|
and `GET /v1/site/{key}/sitemap`. They return online articles, plus the drafts the caller's role may read
|
|
(see [Content permissions](#content-permissions)); any other draft answers 404 `Article not found`.
|
|
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
|
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
|
creator, with that user's role, and shows in full once.
|
|
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
|
|
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
|
|
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
|
|
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
|
|
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
|
|
- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie
|
|
stops working at once; other sessions of the same user stay valid. A password reset ends every
|
|
session and revokes every key of that user.
|
|
- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an
|
|
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
|
|
cross-site request forgery with the cookie; scripts on other origins must send their key.
|
|
|
|
## Routes a key can call (30)
|
|
| Group | Routes |
|
|
| --- | --- |
|
|
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
|
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
|
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
|
|
| Settings | GET /v1/settings |
|
|
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
|
| Authors | GET /v1/author/ |
|
|
|
|
## Activity log
|
|
The activity routes are session only: a key answers 403 "This action needs a real session." and a
|
|
key never reads the audit trail. Events are append-only; there is no `POST`, `PUT`, `PATCH` or
|
|
`DELETE` under `/v1/activity`.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/activity` | Any verified user | Filtered, paged events. A role with `activity.view` at all sees every event; a role at own is scoped to its own events, and `actor` set to someone else answers 403 `missing_permission`. |
|
|
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
|
|
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
|
|
|
|
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
|
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
|
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
|
|
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
|
|
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
|
|
|
|
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
|
|
90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
|
|
`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
|
|
`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
|
|
needs `system.edit_retention` (403 `missing_permission`); any other value answers 400. The daily cleanup
|
|
removes events past the age limit, then the oldest events until the log fits the size limit.
|
|
Whichever limit is reached first wins.
|
|
|
|
### What is recorded
|
|
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
|
|
or after the use case refused it (outcome `failed`, with a plain `failure_reason`). Nothing is
|
|
recorded for a read (no `GET`, no list, no public article read, no status poll), for a request that
|
|
fails validation, or for a target that does not exist (404). A failed audit write is logged and
|
|
never changes the response or the change itself.
|
|
|
|
| Category | Actions |
|
|
| --- | --- |
|
|
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
|
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
|
| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
|
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
|
| `authors` | `author.create`, `author.edit`, `author.delete` |
|
|
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
|
|
|
Target types: `article`, `site`, `author`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
|
`api_key` and `release` (id and name are the tag).
|
|
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`, `site_name`, `title_template`, `default_description`, `bio`, `website`, `social_links`, `avatar`, `user`, `authors`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names; changing the credited authors records `authors` with the names in order; the `user` change of an author holds usernames, never an email), with the
|
|
kinds `text` and `body`; a body edit carries a signed character count delta and never the text; the free-text SEO fields (`excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_alt`) do the same under their own field name, while `slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` (image file names) and the site fields `site_name`, `title_template`, `default_description` carry `before` and `after`. `seo_dismissed` is not an edit and records nothing. A
|
|
change that leaves a value as it was (same body, same publish state, same file name, same display
|
|
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
|
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
|
|
`media.variant_generate_all` carries `generated`, `failed` and `causes` (a list of `too_large`,
|
|
`unsupported_format` or `failed`) and is `failed` only when nothing was generated and something failed;
|
|
`media.variant_purge_image` carries `purged_files` and `purged_bytes`; both have an `image` target
|
|
and are recorded only when files were generated or removed;
|
|
`media.variant_purge_all` and `media.variant_purge_unused` (no target) carry `purged_files` and
|
|
`purged_bytes`, the latter also `older_than_days`; a partial purge adds `failed_files` and stays
|
|
`success`, and a purge that removed nothing while some files failed is `failed`; nothing is recorded
|
|
when no file was removed or failed;
|
|
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
|
|
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
|
|
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
|
|
immediately. A failed registration or invite acceptance has an `anonymous` actor named by the
|
|
attempted username.
|
|
|
|
`failure_reason` is one fixed sentence per domain error, matched with `errors.Is`, at most 255
|
|
characters, for example "The title must be between 3 and 200 characters.", "That file name is not
|
|
valid.", "The merge request is not valid.", "A purge is already running.", "Registration is closed
|
|
on this site.", "That invitation has expired.", "The release feed could not be reached." or "The
|
|
update could not be downloaded.". Any other error becomes "Something went wrong on our side. Please
|
|
try again."; the text of the error never reaches the event, the API or the CSV. Passwords, hashes,
|
|
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
|
|
|
|
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
|
|
`q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`,
|
|
`largest` or `total_size`, the original plus its generated variants), `variants` (`with` or
|
|
`without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50
|
|
or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is
|
|
the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size`
|
|
echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring
|
|
every filter and the paging. Ordering is applied to the whole matching list before slicing, with the
|
|
image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200
|
|
with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or
|
|
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
|
|
(the index rows of the image and their bytes, 0 when none).
|
|
|
|
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
|
|
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
|
|
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
|
|
the id-format message.
|
|
|
|
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
|
|
are no longer parameters; they are ignored, never an error, and have no effect on paging.
|
|
|
|
## Sites
|
|
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
|
|
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
|
|
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
|
|
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
|
|
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
|
|
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
|
|
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
|
|
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
|
|
session only and need `settings.manage_sites` (`settings.delete_site` for DELETE): 403 `missing_permission`, or "This action needs a real
|
|
session." with a key. The role is checked before the body.
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
|
|
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
|
|
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
|
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
|
|
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
|
|
|
|
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
|
|
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
|
|
`invalid_locale`, 400 `invalid_title_template`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
|
|
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
|
|
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
|
|
field that fails validation (`name`, `key` format, `default_robots`, `site_name` over 100 characters, `title_template` over 200, `default_description` over 320) is 400 with the validator text.
|
|
|
|
A site also carries `site_name` (at most 100 characters, `""` by default), `title_template` (at most 200 characters, `%title% | %site%` by default; it must contain `%title%`, case sensitive, otherwise 400 `invalid_title_template`, an empty template included) and `default_description` (at most 320 characters, `""` by default). They appear on every site response, `POST` accepts them and `PATCH` edits them like the other site fields. Existing sites get the defaults when the server starts after the upgrade.
|
|
|
|
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
|
|
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
|
|
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
|
|
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
|
|
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
|
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
|
|
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
|
|
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
|
|
|
|
### Resolved SEO, check, list status and sitemap
|
|
|
|
RenewCMS renders no page and injects no meta tag: it returns the data a frontend needs.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/article/{id}/seo` | public for online articles; a verified session or API key also reads the drafts its role may read | The resolved object `{ title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks, json_ld }`. It never carries the body, the id or `seo_dismissed`, ignores `format`, and writes nothing. An offline article for any other caller, and a missing one, is 404 `Article not found`. |
|
|
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
|
|
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex }]` of the online articles of the site with that key, by id ascending. `url` is `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
|
|
|
|
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed.
|
|
|
|
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
|
|
|
|
### Article SEO fields
|
|
|
|
An article carries twelve search and sharing fields, all edited through `PATCH /v1/article/{id}` and returned by
|
|
every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`):
|
|
`slug`, `excerpt`, `seo_title`, `seo_description`, `cover_image_id`, `cover_image_alt`, `social_title`,
|
|
`social_description`, `social_image_id`, `canonical_url`, `noindex` and `seo_dismissed`. Text fields are `""`
|
|
when empty, the two image ids are `null` when unset, `noindex` is `false` and `seo_dismissed` is `[]` by
|
|
default. Each item of `GET /v1/article/` carries `excerpt` and `slug` and none of the other ten.
|
|
The limits are counted in characters: `excerpt` 300, `seo_title` 120, `seo_description` 320,
|
|
`cover_image_alt` 200, `social_title` 120, `social_description` 320, `canonical_url` 2048; going over is
|
|
400 with the validator text. `cover_image_id` and `social_image_id` accept `null` (and only those two do);
|
|
`0` is 400 with the validator text and an unknown image is 400 `image_not_found`. `canonical_url` is `""` or an absolute
|
|
`https` URL with a host, otherwise 422 `invalid_url`. `seo_dismissed` is a list of the codes `title_long`,
|
|
`title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`
|
|
and `short`: an unknown code is 400 with the validator text and duplicates are collapsed with the order
|
|
kept. `noindex` is only a flag, it does not take the article offline.
|
|
|
|
The `slug` matches `^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters (400 `invalid_slug`) and is unique per
|
|
site, never globally. It is generated from the title when the article is created: lowercase, accents folded,
|
|
every run of other characters becomes one dash, dashes trimmed, cut to 100 characters, and `article` when no
|
|
letter or digit is left; a slug already used on the site gets `-2`, `-3`... silently. The same rule
|
|
backfills the existing articles when the server starts after the upgrade (in id order within each site, so
|
|
the oldest article keeps the plain slug). Sending `slug: ""` regenerates the slug from the title. A slug
|
|
used by another article on the target site (the new site when `site_id` is sent too, and a move re-checks
|
|
the current slug) is 409 with the plain-text body `slug_taken`; the response then carries the header
|
|
`X-Slug-Suggestion` holding the first free `<slug>-N` from 2 on that site, fitting 100 characters. The header
|
|
is set on this 409 only, never on another response. A regeneration that collides is refused the same way, never suffixed
|
|
silently. Saving an article with its own slug is not a collision. Publishing never changes a slug, a deleted article
|
|
frees its slug.
|
|
|
|
New error tokens are plain text from the handler: 409 `slug_taken`, 400 `invalid_slug`, 422 `invalid_url`, 400
|
|
`image_not_found` and 400 `invalid_title_template` (sites). Edits that change a SEO field are recorded as
|
|
`article.edit` events (`slug`, `canonical_url`, `noindex`, `cover_image`, `social_image` with before and after;
|
|
the free-text fields as a size delta only); a refused slug is recorded as failed.
|
|
|
|
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
|
|
image out of the `unused` filter. Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
|
|
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
|
|
`sites_updated` next to `articles_updated`.
|
|
|
|
An article's `cover_image_id` and `social_image_id` count the same way. `used_in` counts every article that uses the
|
|
image in its body, as cover or as social image once, plus the authors using it as an avatar and the sites using it as default
|
|
social image. Deleting an image never fails because of its uses: the covers and social images pointing at it are set to `null`
|
|
(the `cover_image_alt` and the bodies are untouched), and a failed delete leaves every reference in place. A merge or a purge repoints
|
|
the covers and social images of the copies to the kept image, and an article rewritten in its body and its cover counts once in
|
|
`articles_updated`.
|
|
|
|
`GET /v1/media/{id}/usage` stays an array. Every item keeps the keys it had and gains `type` (`article`,
|
|
`author` or `site`) and `fields`. An article item is `{ "type": "article", "id", "title", "published", "fields" }` with
|
|
`fields` from `body`, `cover` and `social` (one item per article, in that order); an author item is
|
|
`{ "type": "author", "id", "title": <name>, "fields": ["avatar"] }` and a site item is `{ "type": "site", "id", "title": <name>,
|
|
"fields": ["default_social_image"] }`; author and site items have no `published` key. The array is `[]` for an unused image.
|
|
|
|
## Authors
|
|
|
|
An author is a public byline, with or without an account. It is `{ id, name, slug, bio, avatar_id,
|
|
website, social_links, user_id, is_linked, article_count }`, plus `user_email` for owners and admins
|
|
only (absent for every other caller). `name` is 1 to 100 characters after trimming, `slug` matches
|
|
`^[a-z0-9]+(-[a-z0-9]+)*$` with 1 to 100 characters and is unique (generated from the name when
|
|
omitted: accents folded, punctuation runs become one dash, `author` when nothing is left), `bio` has
|
|
at most 280 characters (counted in runes), `avatar_id` is `null` or an image id, `website` is `null`
|
|
or an `https` URL with a host (an empty string means none, at most 2048 characters),
|
|
`social_links` holds at most 5 `{ "type", "url" }` with `type` one of `x`, `linkedin`, `github`,
|
|
`mastodon`, `custom` and an `https` URL. `user_id` is the unique link to a user. Zero authors on an
|
|
article is allowed and never blocks publishing.
|
|
|
|
| Route | Who | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
|
|
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
|
|
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
|
|
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
|
|
| `DELETE /v1/author/{id}?reassign_to={id\|none}` | owner or admin, session only | 204. An author credited on articles needs `reassign_to`: an author id (that author takes the deleted one's position and is never duplicated on an article) or `none` (the credit is removed). The move and the delete are one transaction. `reassign_to` is ignored when the author has no article. The linked account is untouched. |
|
|
|
|
The role checks use the existing bodies: 403 `missing_permission` for `POST /` and
|
|
`DELETE`, 403 `author_rights` for a `PATCH` by a role that holds `authors.edit` at own on an author
|
|
that is not its own (or that sends `user_id`), and 403 "This action needs a real session." for a key.
|
|
Errors are plain text whose body is exactly the token: 404 `author_not_found`, 404 `user_not_found`,
|
|
409 `author_slug_taken`, 409 `author_has_articles`, 409 `user_already_linked`, 409
|
|
`author_already_linked`, 400 `invalid_slug`, 400 `invalid_reassign` (own id, not a whole number,
|
|
negative or empty), 400 `image_not_found`, 422 `bio_too_long`, 422 `invalid_url` and 422
|
|
`too_many_links`. A body that cannot be decoded, an empty `PATCH` or one that nulls a required field
|
|
is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message and other
|
|
validation failures (`name`, link `type`) are 400 with the validator text. Writes refused for a
|
|
reason other than a missing target or an invalid value are recorded as failed events.
|
|
|
|
Articles carry `authors`, the ordered credits (the first is the primary byline), each with only `id`,
|
|
`name`, `slug`, `bio`, `avatar_id`, `website` and `social_links`; it is `[]` when there is none and it
|
|
is visible on online articles to anonymous readers. `PATCH /v1/article/{id}` also takes
|
|
`author_ids`, the whole ordered list: it replaces the credits, `[]` empties them, an unknown id is 404
|
|
`author_not_found`, a duplicate, `null` or a malformed list is 400 with the body-mismatch message and
|
|
`0` is 400 with the validator text. Absent means unchanged. A new article is credited to the
|
|
creator's linked author when there is one. `GET /v1/auth/me` returns the existing user fields plus
|
|
`author`: the caller's linked author (`id`, `name`, `slug`, `bio`, `avatar_id`, `website`,
|
|
`social_links`, `article_count`) or `null`.
|
|
|
|
An author's `avatar_id` counts as a use of that image like a site's social image: it raises `used_in`
|
|
and keeps the image out of the `unused` filter, and `GET /v1/media/{id}/usage` lists the author with the field `avatar`.
|
|
Deleting the image sets `avatar_id` to `null`. `POST /v1/media/merge` and `POST /v1/media/purge`
|
|
repoint the avatars of the merged copies and report it as `authors_updated`.
|
|
|
|
## Image variants
|
|
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
|
key) and sit behind the verified-user check; a role without the needed permission gets 403 `missing_permission`. Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
|
|
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
|
|
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
|
|
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
|
|
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
|
|
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
|
|
| `GET /v1/image-variants/cache` | Cache statistics, see below. |
|
|
| `POST /v1/image-variants/cache/purge` | Purge generated files by scope, see below. |
|
|
|
|
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
|
|
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
|
|
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
|
|
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
|
|
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
|
|
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
|
|
|
|
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
|
|
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
|
|
field (name, width and height, fit, format, quality).
|
|
|
|
### Cache statistics and purge by scope
|
|
|
|
`GET /v1/image-variants/cache` answers `{ generated_files, disk_used, variants_defined,
|
|
variants_enabled, variants_disabled, rejected_requests, rejected_window_days, unused: { files, bytes,
|
|
days }, by_variant: [{ name, enabled, files, bytes }], top_images: [{ id, filename, url, files, bytes }] }`.
|
|
`by_variant` lists every defined variant by bytes descending then name; `top_images` lists the ten
|
|
images using the most variant bytes (ties by id, images that no longer exist are skipped). `unused`
|
|
counts the files of enabled variants whose reference time (the last public request, or the creation
|
|
time when never requested) is older than 30 days. `rejected_requests` counts the public requests
|
|
refused with 400 over today and the six previous UTC days. Pending request tracking is flushed before
|
|
the read. Arrays are never `null`.
|
|
|
|
`POST /v1/image-variants/cache/purge` takes `{ "scope": "all" | "unused" | "variant" | "image",
|
|
"variant": "<name>", "image_id": <id> }` (`variant` for the scope `variant`, `image_id` for `image`; a
|
|
target given for another scope is ignored) and answers `{ scope, purged_files, purged_bytes,
|
|
failed_files }`. A purge that fails on some files still answers 200 with `failed_files` above 0; the
|
|
failed rows are kept so the same request can be sent again. Errors are plain text: 400 for a body that
|
|
is not valid JSON, 422 "Scope must be all, unused, variant or image." or "A variant name or an image
|
|
id is required for this scope.", 404 "Variant not found" or "Image not found", 500 when the rows
|
|
cannot be listed.
|
|
|
|
### Variants of one image
|
|
Owners and admins only, session only: a request with an API key is 401. All ids are image ids.
|
|
|
|
| Route | Purpose |
|
|
| --- | --- |
|
|
| `GET /v1/media/{id}/variants` | `{ "unsupported": null, "items": [...] }` for the enabled variants in name order. `unsupported` is `"svg"` (with `items: []`) for an SVG. |
|
|
| `POST /v1/media/{id}/variants` | Generates every enabled variant that is missing. `{ "generated", "failed", "failures": [{ "name", "cause" }], "items": [...] }`, 200 even when some fail. |
|
|
| `POST /v1/media/{id}/variants/{name}` | Generates one enabled variant. 200 with the item. |
|
|
| `DELETE /v1/media/{id}/variants/{name}` | Removes the generated file. `{ "purged_files", "purged_bytes" }`, also for a disabled variant. |
|
|
|
|
An item is `{ name, width, height, fit, format, output_width, output_height, output_format,
|
|
generated, size, url }`: `width` and `height` are the settings (`null` when auto), `output_*` the
|
|
size and format the file has or will have (never above the source), `generated` is true only when
|
|
the file exists with the variant's current settings, `size` is its bytes or `null`, and `url` is
|
|
`/static/uploadedImages/<stored name>?v=<name>`. A `cause` is `too_large`, `unsupported_format` or
|
|
`failed`. A file generated here is the same file the public url serves, and the other way round.
|
|
Uploads generate the variants flagged `generate_on_upload` (enabled ones, raster sources only)
|
|
before answering; a failure there is logged and never fails the upload.
|
|
|
|
Errors are plain text: 400 for a malformed id, 404 "Image not found" or "Variant not found" (also for a
|
|
disabled variant on `POST`), 413 "The source image is too large to generate this variant.", 415 "The
|
|
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
|
|
this format." and 500 "Internal server error".
|
|
|
|
## Roles
|
|
|
|
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
|
|
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
|
|
|
|
| Route | Permission | Purpose |
|
|
| --- | --- | --- |
|
|
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
|
|
| `GET /v1/role/{id}` | `roles.view` | One role. |
|
|
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
|
|
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
|
|
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
|
|
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
|
|
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
|
|
|
|
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
|
|
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
|
|
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
|
|
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
|
|
the users holding the role plus the open invites (pending or expired, never accepted ones) for it, counted in two
|
|
statements whatever the number of roles. A user whose `role_id` is 0 or names no role counts for nobody.
|
|
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
|
|
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
|
|
|
|
Refusals are plain text, one snake_case code per body:
|
|
|
|
| Status | Body | When |
|
|
| --- | --- | --- |
|
|
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
|
|
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown). |
|
|
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
|
|
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
|
|
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
|
|
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
|
|
| 404 | `role_not_found` | Unknown role id. |
|
|
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members (users or open invites); deleting or moving members out of a built-in role. |
|
|
|
|
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
|
|
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
|
|
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
|
|
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
|
|
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
|
|
|
|
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
|
|
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
|
|
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` (users and open invites together) and is recorded only when
|
|
`moved` is above 0. A refusal records nothing.
|
|
|
|
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
|
|
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
|
|
creating one with the key of a deleted type clears any stale row for that key.
|
|
|
|
## Users, invites and ownership
|
|
|
|
A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the
|
|
`users.role` column of older databases stays in place, unused). `RoleRef` is the one role summary shape,
|
|
`{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items
|
|
(`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`,
|
|
`email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login,
|
|
verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`.
|
|
|
|
`role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column
|
|
`role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it
|
|
is read from the current record of that user at read time (`display_name` when set, else `username`), so a rename shows
|
|
at once. When that record is gone the whole object is `null`. It is set by `PUT /v1/user/{id}/role` to the caller (a
|
|
request with the user's current role writes nothing), by invite accept to the inviter (`NULL` when the inviter record is
|
|
missing), and by the ownership transfer: the new Owner is assigned by the former Owner, whose own value becomes `NULL`.
|
|
First-user registration and `POST /v1/role/{id}/reassign` leave it alone. The key is always present on accepted-user
|
|
items of `GET /v1/user` (gate `users.view`, unchanged; assigners are resolved from the users already loaded) and on
|
|
`access` of `GET /v1/auth/me` (own data, one extra lookup at most). Invite items never carry it, and `PublicUser` and the
|
|
login, verify, register and invite-accept bodies do not change.
|
|
|
|
| Route | Permission | Session only | Purpose |
|
|
| --- | --- | --- | --- |
|
|
| `GET /v1/user` | `users.view` | no | Users and open invites, newest first. A user is `{ id, kind: "user", username, display_name, email, role, role_assigned_by, status: "accepted", created_at }`; an invite is `{ id, kind: "invite", email, role, status, created_at }`. The roles are read with one `List` call. |
|
|
| `PUT /v1/user/{id}/role` | `users.change_role` | yes | Body `{"role_id": N}` (required). Answers 200 with the user item, whose `role_assigned_by` is the caller. |
|
|
| `POST /v1/user/invite` | `users.invite` | no | Body `{"email", "role_id"}`; `role_id` is optional and 0 or absent means the Writer role. Answers 201 `{ id, kind: "invite", email, status, role, created_at, expires_at }`. |
|
|
| `POST /v1/user/invite/{id}/renew` | `users.invite` | no | Renews an expired or pending invite; it keeps its role and the response carries it. |
|
|
| `DELETE /v1/user/invite/{id}` | `users.invite` | no | Cancels an invite (204). No coverage is needed: cancelling removes power. |
|
|
| `POST /v1/site/transfer-ownership` | `system.transfer_ownership` | yes | Body `{"user_id", "confirm_email"}` (both required). Answers 200 `{ "owner": { id, name }, "former_owner": { id, name, role } }`. |
|
|
|
|
The no-escalation rule applies to people as it does to roles: the caller must cover (`GrantSet.Covers`) the target's
|
|
current role and the new role on a role change, the role of an invite on create and renew. An API key may create and
|
|
renew invites, and the check then uses the live role of the key's creator. Nobody can give, take away or invite to the
|
|
Owner role: `PUT /v1/user/{id}/role` to or from it, and an invite for it, are 403 `role_locked`; only the transfer moves
|
|
the Owner role. Changing the role of your own account is 403 `own_role`. Giving the role a user already holds answers
|
|
200 and writes and records nothing. A role change keeps `token_version`, sessions and keys: the next request is decided
|
|
by the new grants. An unverified user may be given a role.
|
|
|
|
The order of checks on `PUT /v1/user/{id}/role` is: session, permission, id, body, target user, target holds a locked
|
|
role, `role_id` lookup, new role locked, own account, current role covered, new role covered, same role (no-op), write.
|
|
On `POST /v1/user/invite`: permission, rate limiter, body, inviter, role (default Writer), role locked, role covered,
|
|
email already has an account, pending invite, create, mail. On `POST /v1/user/invite/{id}/renew`: invite lookup (404),
|
|
already accepted (409), invite role covered, renew, mail. On `POST /v1/site/transfer-ownership`: session, permission,
|
|
body, target, `confirm_email`, write. Every refusal writes nothing, not even the deletion of an expired invite.
|
|
|
|
| Status | Body | When |
|
|
| --- | --- | --- |
|
|
| 409 | the human message | `POST /v1/user/invite` only. "This address already has an account." carries `X-Error-Code: email_has_account`; "There's already a pending invite for this address." carries `X-Error-Code: invite_pending`. |
|
|
| 400 | `invalid_role` | `role_id` names no role (user change or invite), or the invite's role no longer exists on renew. |
|
|
| 400 | `invalid_transfer` | The transfer target is unknown, yourself, unverified, or not a user (an invite id is never valid). |
|
|
| 400 | `confirmation_mismatch` | `confirm_email` differs from the target's email (exact, case-sensitive match). |
|
|
| 403 | `role_locked` | The target holds the Owner role, or the role to give is the Owner role. |
|
|
| 403 | `own_role` | The target of a role change is the caller's own account. |
|
|
| 403 | `escalation` | A role to cover is above the caller's grants; `X-Escalation-Key` names the first offending key. |
|
|
| 403 | `missing_permission` | The gate, with `X-Missing-Permission`. A key on a session-only route answers 403 "This action needs a real session." first. |
|
|
| 404 | `user_not_found` | Unknown user id on `PUT /v1/user/{id}/role`. |
|
|
| 409 | `user_role_changed` | The target's role changed between read and write (a compare-and-swap on the role that was read), or the caller no longer holds the Owner role on a transfer. |
|
|
|
|
Transferring ownership moves the Owner role to the target and gives the former owner the built-in Admin role in one
|
|
transaction; both keep `token_version`, cookies and API keys, so the former owner's next request is decided by the
|
|
Admin grants and the new owner's by the Owner grants. Exactly one verified user holds the Owner role afterwards, also
|
|
under two simultaneous transfers (one answers 200, the other 409).
|
|
|
|
Accepting an invite (`POST /v1/auth/invite/accept`) creates the user with the role the invite names, read at accept
|
|
time; an invite whose `role_id` is 0 or names a deleted role answers the usual invalid-invite 404 and creates nothing.
|
|
Registration creates the first user with the built-in Owner role; when the Owner role is not seeded it answers 500.
|
|
A user whose `role_id` is 0 or names no role is denied on every gated route (403 `missing_permission`) and gets 500 on
|
|
`GET /v1/auth/me`; it is never promoted.
|
|
|
|
Activity: `user.change_role` (category `users`, target the user, `changes` holds `{ field: "role", kind: "text", before,
|
|
after }` with the role names, params `from` and `to` hold the role ids) and `user.transfer_ownership` (category `users`,
|
|
`security: true`, target the new owner, params `from` and `to` hold the two user ids). `invite.create` and
|
|
`invite.accept` carry the params `role_id` and `role` (the role name); `invite.renew` is unchanged. A refusal and a
|
|
no-op record nothing; a storage failure at the write records the same event with outcome `failed`.
|
|
|
|
## Content permissions
|
|
|
|
Articles and the entries of custom content types are checked against the `content.*` permissions of the
|
|
caller's role, which is read on every request (a key follows its creator's role).
|
|
|
|
| Route | Permission | Scope check after the lookup |
|
|
| --- | --- | --- |
|
|
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
|
|
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
|
|
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
|
|
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
|
|
| `DELETE /v1/article/{id}` | `content.delete` | same |
|
|
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
|
|
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
|
|
| `POST /v1/content-type/{key}/entry` | `content.create` for `{key}` | `created_by` is the caller |
|
|
| `PUT`, `DELETE .../entry/{id}` | `content.update`, `content.delete` for `{key}` | `own` needs `created_by` to be the caller |
|
|
|
|
Each action reads its own permission: publish does not imply update, update does not imply read. `own`
|
|
means `created_by` equals the caller; an unattributed item (`created_by` 0) is owned by nobody. A permission
|
|
can also be set for one content type (`content.update:blog`, `content.update:article` for articles); that
|
|
row replaces the wildcard for the type, whether it lowers or raises it, and a `none` row denies. Only the
|
|
wildcard applies to a type with no row. An id that does not exist is 404 even for a caller at `own`. Article responses expose `created_by` to signed-in callers and API keys only (never to anonymous callers), so a client can tell which rows are its own; see `docs/api/articles.md`.
|
|
|
|
A refusal is 403 `missing_permission` with `X-Missing-Permission` set to the base key (`content.update`,
|
|
never `content.update:blog`). The permission is checked before the id and the body are read, then the scope
|
|
after the lookup, and a refused call writes nothing and records no activity event. The verification and
|
|
token checks still come first. Creating a content type whose key is `article`, `author`, `site`, `media`
|
|
or `user` is 409 `content_type_key_taken`. Owner-only permissions only count on the locked Owner role: the
|
|
same key placed by hand on another role is ignored and `GET /v1/auth/me` does not list it.
|
|
|
|
## Errors
|
|
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
|
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
|
|
|
A caller whose role lacks the permission a route needs gets 403 with the body `missing_permission` and the
|
|
header `X-Missing-Permission` set to the permission key (for example `users.invite`). The role is read on
|
|
every request, so an API key follows the current role of its creator.
|
|
|
|
## Notes
|
|
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
|
- Browsers can call the API only from origins in the server's CORS setting.
|