Chapter 21Reference
Route reference
The four routes: requests, responses, codes, caps. What they accept, what they refuse, and in which order.
What the four share
- JSON responses,
cache-control: no-store. - Any unexpected method gets
405. - The order of checks is the same everywhere: rate → declared size → identity → received size → shape → business rules.
- Error messages carry no technical detail. Detail goes to the server logs.
- No response contains a secret: no token, no hash, no session content.
POST /api/auth
Opens a session from the site key. It is the only route that evaluates an identity.
curl -i -X POST https://the-site.com/api/auth \
-H "content-type: application/json" \
-d '{"key":"the-site-key"}'set-cookie: inline_session=…; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=28800
set-cookie: inline_edit=1; Secure; SameSite=Strict; Path=/; Max-Age=28800
{ "ok": true }| Code | Body | When |
|---|---|---|
200 | { "ok": true } | Correct key. Two cookies set. |
401 | { "error": "clé incorrecte" } | Wrong key, missing, too long, unreadable body, or missing configuration. The same message in every case. |
429 | { "error": "Trop de tentatives…" } | More than 5 attempts per quarter of an hour. retry-after header. |
405 | { "error": "method_not_allowed" } | Any method other than POST or DELETE. |
Cost: about 350 ms, the time argon2id takes. Deliberate — it is what makes brute force impractical. Body capped at 2,000 bytes, key at 256 characters: beyond that, nothing is computed.
DELETE /api/auth
Closes the session: both cookies are cleared. Always answers 200.
GET /api/content
Reads the repository's real state and the reference version for the optimistic lock. The overlay calls it on load.
curl -s "https://the-site.com/api/content?path=src/content/pages/en/home.json" \
--cookie "inline_session=…"{
"content": "{\n \"meta\": { … }\n}\n",
"version": "3f2a1c…"
}| Code | When |
|---|---|
200 | Read succeeded. content is the whole file, as text. |
400 | path missing or outside the whitelist. |
401 | No valid session. |
404 | The file does not exist in the repository. |
429 | More than 60 reads per 5 minutes. |
502 | The repository is unreachable or refuses access. |
POST /api/save
Validates and publishes a page. It is the most controlled route in the project.
{
"path": "src/content/pages/en/home.json",
"content": "{ … the whole JSON file, as text … }",
"version": "3f2a1c…",
"message": "content(en): home — hero.title"
}| Field | Constraint |
|---|---|
path | src/content/pages/{declared language}/{page}.json, 120 characters max. |
content | The whole file, not a diff. 100 KB max. |
version | The one received from /api/content. Empty for a new file. 200 characters max. |
message | Optional. Reduced to a single 120-character line, no control characters. |
{ "version": "9b7e42…" }| Code | Body | When |
|---|---|---|
200 | { "version": … } | Written. The new version is used by the next publish. |
400 | bad_request | Unreadable body, path outside the whitelist, missing field. |
401 | unauthorized | No valid session. |
409 | conflict | The version no longer matches: someone published in the meantime. |
413 | too_large | Content > 100 KB, or envelope > 128 KB. |
422 | invalid_content | Zod schema, invalid media, duplicate ids, hostile markup. |
429 | wait message | More than 30 publishes per 5 minutes. |
502 | not_found, unauthorized, unavailable | The repository refused or did not answer. |
Exactly what was just validated and sanitised, re-serialised as indented JSON. It starts from the parsed object rather than Zod's output: the latter would strip any key it does not know yet.
POST /api/upload
Receives an image already cropped and converted by the browser, checks it, renames it and stores it in the repository.
file the image file (JPEG, PNG or WebP), 20 MB max
name desired name, 200 characters max — indicative, always rewritten{ "src": "bakehouse-at-dawn.webp", "width": 1600, "height": 900 }| Code | Body | When |
|---|---|---|
200 | { src, width, height } | Stored. src is the rewritten name, to be copied as-is into the content. |
400 | bad_request | Unreadable form, missing file field, or name refused after rewriting. |
401 | unauthorized | No valid session. |
413 | too_large | More than 20 MB, declared or received. |
415 | unsupported_format + kind | Format not recognised from the bytes, or dimensions out of range (1 to 10,000 px). kind says what was recognised. |
429 | wait message | More than 30 uploads per quarter of an hour. |
502 | repository code | Write impossible. |
Not the announced MIME type, not the file name, not the dimensions. The format is recognised from its bytes, dimensions are read from the header, the name is rewritten — then re-checked against the same whitelist imposed on content references.
The router
A site declares its routes once, and the host plugs into them. The declaration is one line: the only decision that belongs to the site is its locales.
import { createRouter } from 'inline-core/server';
import { LOCALES } from './locales';
export const api = createRouter({ locales: LOCALES });
createRouter returns the four routes in three shapes. Each host takes the one it
needs — none is preferred, and the package knows about none of them.
| Shape | For which host |
|---|---|
api.routes['/api/save'] | one that discovers routes from a file tree and expects named exports |
api.handle(request, env) | every other one: a single entry point that picks the route and the method |
api.find(pathname) | the route serving a path, or undefined — enough to split API from static in a server of your own |
env carries whatever the host exposes: the variables, and any bindings — including
RATE_LIMIT. An unserved method gets a 405, an unknown path a 404, and always as
JSON: the caller is the overlay, and an error page teaches it nothing.
Before, each site kept the four factories in four files written to one host's convention. Dispatch — which path, which method, which response — now lives in the package and is updated with it.
The factories
They are still exported, for wiring by hand: createRouter only gathers them. A
site on 2.0.x has nothing to change.
import {
createAuthRoute, // () → onRequest, onRequestPost, onRequestDelete
createContentRoute, // ({ locales }) → onRequest, onRequestGet
createSaveRoute, // ({ locales }) → onRequest, onRequestPost
createUploadRoute, // () → onRequest, onRequestPost
} from 'inline-core/server';
SiteConfig holds one thing: locales. If that interface grows, it is a
sign a package decision has leaked into the sites.
Rate budgets
| Route | Budget | Window | What it protects |
|---|---|---|---|
/api/auth | 5 | 15 min | the site key |
/api/content | 60 | 5 min | the repository API quota |
/api/save | 30 | 5 min | the repository, the quota |
/api/upload | 30 | 15 min | the repository, the quota |
Caps
| Cap | Value |
|---|---|
| Page content | 100,000 bytes |
| JSON request envelope | 128,000 bytes |
/api/auth body | 2,000 bytes |
| Key length | 256 characters |
| Uploaded file | 20 MB |
| Image dimensions | 1 to 10,000 px |
| Path length | 120 characters |
| Commit message | 120 characters, one line |
| Session lifetime | 8 hours |