Feature: Integration screen (API reference) As a developer building a frontend or an API-key client for a RenewCMS site I want /integration to be a complete, searchable API reference So that I can call every route a key may call without reading the source Background: Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage inside DashboardShell, per docs/design/routes.md And the screen makes no /v1 calls; api: none (static) And the PageHeader shows breadcrumb "Integration", title "API reference" and description "Everything a developer needs to build on RenewCMS." And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order: | group | routes | endpoints | | Articles | 7 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Media | 6 | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage | | Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me | | Settings | 1 | GET /v1/settings | And the "Session only" group has 13 non-expandable rows in 3 sub-groups: | sub-group | rows | | System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart | | Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset | | API key management | GET /v1/api-key, POST /v1/api-key, DELETE /v1/api-key/{id} | And a "Public auth flow" sub-list follows with 6 non-expandable rows and the chip "Public": | method | path | description | | GET | /v1/ | Hello world. | | GET | /v1/auth/registration | Whether registration is open. | | POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. | | GET | /v1/auth/invite | Check an invite code. | | POST | /v1/auth/invite/accept | Accept an invite. | | GET | /healthz | Plain text "ok". Outside /v1. | And every catalogue endpoint has a non-empty description, method, path, access chip, key chip, query-parameter table (or "No query parameters."), request-body table (or "No request body."), fetch, curl and Response examples, and error captions Scenario: Default, every card collapsed When the user navigates to /integration Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office." And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy) And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile Scenario Outline: Method badge and chips per endpoint Then shows the badge, access chip "" and key chip "API key OK" Examples: | method | path | access | | GET | /v1/article/ | Public | | GET | /v1/article/{id} | Public | | POST | /v1/article/ | Verified user | | DELETE | /v1/article/{id} | Verified user | | GET | /v1/media/ | Verified user | | PATCH | /v1/user/me | Verified user | | GET | /v1/auth/me | Verified user | | POST | /v1/user/invite | Owner only | | DELETE | /v1/user/invite/{id} | Owner only | | POST | /v1/user/invite/{id}/renew | Owner only | | GET | /v1/settings | Verified user | Scenario: Owner-only invite cards, user-approved divergence from the board Then the three invite cards show "Owner only", where the board shows "Verified user" And each lists a 403 error "Only the owner can do this." And each says it is rate limited and that too many requests answer 429, with no number Scenario: Group scrolled, one card expanded When the user activates the "POST /v1/article/" row Then the row's button reports aria-expanded "true" And a "Query parameters" table lists "format", string, optional, "markdown, html or json. Default markdown." And a "Request body" table lists: | Name | Type | Required | Rule | | title | string | Yes | 3 to 200 characters. Can't be changed later. | | body | string | Yes | Up to 10 000 characters. | And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button And an "Errors" list shows a status chip with a one-line caption for 400, 401 Scenario: Query parameters table, new UI beyond the board, user-approved Then the "Query parameters" table sits above the "Request body" table in each card And it lists: | route | name | type | required | rule | | every /v1/article route | format | string | no | markdown, html or json. Default markdown. | | GET /v1/media/ | q | string | no | Search text. | | GET /v1/media/ | hash | string | no | SHA-256 of the content, lowercased. | | GET /v1/media/ | cursor | string | no | The next_cursor of the previous page. | | GET /v1/media/ | filter | string | no | all, unused or duplicates. Default all. | | GET /v1/media/ | sort | string | no | newest, oldest, name or largest. Default newest. | | GET /v1/media/ | limit | number | no | 1 to 100. Default 24. | And a card without query parameters shows "No query parameters." Scenario Outline: Request body fields come from the handler validate tags Then lists these fields: Examples: | method | path | fields | | POST | /v1/article/ | title string required 3-200; body string required max 10000 | | PATCH | /v1/article/{id} | body string required max 10000 | | PATCH | /v1/media/{id} | filename string required | | POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 | | POST | /v1/media/ | multipart/form-data, 1 file field named "file" | | PATCH | /v1/user/me | display_name string required max 60 | | POST | /v1/user/invite | email string required valid email | | GET | /v1/article/ | "No request body." | Scenario: Response samples use the real domain JSON field names Then the GET /v1/article/{id} Response sample has exactly the keys id, title, body, body_format, is_online, created_at, updated_at And the sample has no "images" key and no "status" key And POST /v1/media/merge has the keys articles_updated and merged And GET /v1/media/ has the keys items, next_cursor and counts And GET /v1/media/{id}/usage items have the keys id, title, published And PATCH /v1/article/{id}/publish and /unpublish samples are articles with is_online true and false Scenario: Article body format Then a note reads "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default." And every article card's Errors list includes 400 "The format must be one of: markdown, html, json." Scenario: Authentication section Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id} And card "API key" reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." And its CodeBlock has tabs curl and fetch for GET /v1/auth/me, using the current origin And it says "Keys start with `rcms_` and are shown in full once, when you create them." And a "Manage API keys" link navigates to /integration/api-keys And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." Scenario: Session only group Then the banner reads "These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."" And each row has a dashed border and muted fill, uses no method colour and cannot be expanded And rows that answer 403 to a key show the lock icon and the chip "Session only" And Log in, Log out, Register and Reset password show the caption "Made for the browser. A key is ignored here, with no 403." and no 403 claim And no /v1/system route appears in any expandable group And the "Public auth flow" rows carry the chip "Public" and no lock icon Scenario: Errors section Then the intro reads "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." And the table lists: | status | message | | 400 | The request cannot be processed due to a mismatch in the format of the body. / The expiry must be a date in the future. | | 401 | You must be logged in to access this resource. | | 403 | This action needs a real session. / Only the owner can do this. / You must verify your account to access this resource. | | 404 | Article not found / Image not found / Invite not found / API key not found | | 409 | There's already a pending invite for this address. / This invite has already been accepted. | | 429 | Too many requests. Please try again later. | | 500 | Internal server error | And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session." And the table does not use the SPA copy of docs/design/states.md Scenario Outline: Per-endpoint error captions use the real strings Then lists "" Examples: | method | path | status | caption | | POST | /v1/media/ | 400 | the image could not be read | | POST | /v1/media/ | 413 | the file exceeds the maximum upload size | | POST | /v1/media/ | 415 | the file must be a PNG, JPEG, WEBP, or SVG image | | GET | /v1/media/ | 400 | The media query is invalid. | | POST | /v1/media/merge | 400 | Choose an image to keep and at least one other image to merge into it. | | POST | /v1/media/merge | 409 | Some of the images to merge changed. Refresh and try again. | | PATCH | /v1/media/{id} | 400 | Enter a filename. | | POST | /v1/article/ | 400 | Article title must be between 3 and 200 characters. | | GET | /v1/article/{id} | 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). | | PATCH | /v1/user/me | 400 | Display name must be 60 characters or fewer. | | POST | /v1/user/invite | 409 | This address already has an account. | | POST | /v1/user/invite | 409 | There's already a pending invite for this address. | | POST | /v1/user/invite/{id}/renew | 409 | This invite has already been accepted. | | POST | /v1/user/invite | 403 | Only the owner can do this. | Scenario: Notes Then the image-prefix note reads "Images in body use paths like /static/uploadedImages/.png. Prefix them with the base URL before rendering." And the CORS note reads "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address." Scenario: Copied, Base URL Given the app is served from origin "{origin}" When the user activates the Base URL "Copy" button Then "{origin}" is written to the clipboard And the button shows icon check and "Copied", announced to assistive technology When 2 seconds elapse Then the button reverts to icon copy and "Copy" Scenario: Copied, CodeBlock is independent of Base URL Given a card is expanded When the user activates that CodeBlock's "Copy JavaScript example" button Then that card's exact code string is written to the clipboard and only that button reads "Copied" And the Base URL button and other CodeBlocks are unaffected Scenario: Examples use the current origin and the API key header Then every fetch and curl example starts from "{origin}" and carries no ?format= query And examples for non-public routes carry "Authorization: Bearer rcms_…" And examples for public routes carry no Authorization header Scenario: Navigation When the user activates a section in the index (desktop) Then the page scrolls to that anchor and the item reports aria-current "true" And on mobile the "Jump to" select opens and lists the same sections Scenario: The page stays static Then no /v1 request is made while the page renders or while any card is expanded