398 lines
12 KiB
Markdown
398 lines
12 KiB
Markdown
|
|
# HTTP API
|
||
|
|
|
||
|
|
Two surfaces with different postures:
|
||
|
|
|
||
|
|
| | `/api/v1` | `/rt/v1` |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| Audience | Administrators | Applications and browsers |
|
||
|
|
| Authentication | Required | None, by design |
|
||
|
|
| Caching | `no-store` | Aggressive, immutable by URL |
|
||
|
|
| Audit | Every mutation | No |
|
||
|
|
| Rate limit | Generous | Applied |
|
||
|
|
|
||
|
|
A theming service that required a credential could not be consumed from a
|
||
|
|
`<link>` tag, which is the primary use case.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Conventions
|
||
|
|
|
||
|
|
- All bodies are JSON. One shape, everywhere.
|
||
|
|
- Errors: `{ code, message, details?, requestId? }`. `code` is a stable
|
||
|
|
machine-readable identifier and is part of the contract.
|
||
|
|
- `requestId` is on every error and in `X-Request-ID` on every response.
|
||
|
|
- Timestamps are RFC 3339 in UTC.
|
||
|
|
- Every mutable request accepts `baseVersion` for optimistic concurrency.
|
||
|
|
|
||
|
|
| Code | Status | Meaning |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `bad_request` | 400 | Malformed |
|
||
|
|
| `validation_failed` | 400 | Invalid, with per-field `details` |
|
||
|
|
| `unauthorized` | 401 | No usable credential |
|
||
|
|
| `forbidden` | 403 | Insufficient role |
|
||
|
|
| `not_found` | 404 | Missing, or deliberately not disclosed |
|
||
|
|
| `conflict` | 409 | Uniqueness, or an optimistic-concurrency conflict |
|
||
|
|
| `immutable` | 409 | A built-in theme cannot be deleted |
|
||
|
|
| `rate_limited` | 429 | With `Retry-After` |
|
||
|
|
| `internal_error` | 500 | Unexpected |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Runtime API
|
||
|
|
|
||
|
|
### `GET /rt/v1/theme.css`
|
||
|
|
|
||
|
|
A theme's stylesheet.
|
||
|
|
|
||
|
|
| Parameter | Required | Default | Purpose |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| `slug` | Yes | — | The theme |
|
||
|
|
| `appearance` | No | The theme's default | `light` or `dark` |
|
||
|
|
| `variant` | No | `full` | `full`, `tokens`, `minified` |
|
||
|
|
| `v` | No | Latest | Pin a version |
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight'
|
||
|
|
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&appearance=dark'
|
||
|
|
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&variant=tokens'
|
||
|
|
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7'
|
||
|
|
```
|
||
|
|
|
||
|
|
```
|
||
|
|
HTTP/1.1 200 OK
|
||
|
|
Content-Type: text/css; charset=utf-8
|
||
|
|
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
|
||
|
|
ETag: "a1f4b5833f60d17d42328a90213b20f6"
|
||
|
|
X-Theme-Version: 7
|
||
|
|
X-Theme-Updated-At: 2026-10-04T09:14:27Z
|
||
|
|
Vary: Accept-Encoding
|
||
|
|
```
|
||
|
|
|
||
|
|
A draft theme returns **404**, not 403, so a probe cannot confirm it exists.
|
||
|
|
|
||
|
|
### `GET /rt/v1/theme.json`
|
||
|
|
|
||
|
|
Resolved tokens.
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"theme": {
|
||
|
|
"slug": "midnight",
|
||
|
|
"name": "Midnight",
|
||
|
|
"version": 7,
|
||
|
|
"appearance": "dark",
|
||
|
|
"defaultAppearance": "dark",
|
||
|
|
"updatedAt": "2026-10-04T09:14:27Z"
|
||
|
|
},
|
||
|
|
"variables": {
|
||
|
|
"--bn-color-semantic-primary": "#7c6bf5",
|
||
|
|
"--bn-color-on-primary": "#0f0d1f",
|
||
|
|
"--bn-radius-lg": "0.75rem",
|
||
|
|
"--bn-space-4": "1rem"
|
||
|
|
},
|
||
|
|
"tokens": {
|
||
|
|
"color": { "semantic": { "primary": { "$type": "color", "$value": "#7c6bf5" } } }
|
||
|
|
},
|
||
|
|
"meta": { "tags": ["dark", "violet"] }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`variables` is a flat map for anything reading values. `tokens` is the nested DTCG
|
||
|
|
document for mapping onto component props.
|
||
|
|
|
||
|
|
### `GET /rt/v1/manifest.json`
|
||
|
|
|
||
|
|
The discovery document for building a picker. Deliberately small: no token data.
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"apiVersion": "1",
|
||
|
|
"default": "midnight",
|
||
|
|
"baseUrl": "https://themes.example.com/rt/v1",
|
||
|
|
"themes": [
|
||
|
|
{
|
||
|
|
"slug": "midnight",
|
||
|
|
"name": "Midnight",
|
||
|
|
"description": "Violet-accented dark interface.",
|
||
|
|
"defaultAppearance": "dark",
|
||
|
|
"version": 7,
|
||
|
|
"tags": ["dark", "violet"],
|
||
|
|
"css": "https://themes.example.com/rt/v1/theme.css?slug=midnight",
|
||
|
|
"tokens": "https://themes.example.com/rt/v1/theme.json?slug=midnight"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### `GET /rt/v1/embed`
|
||
|
|
|
||
|
|
A ready-to-paste snippet. `?target=html` (the default, a full preview page), `react`,
|
||
|
|
or `javascript`.
|
||
|
|
|
||
|
|
### SDK bundles
|
||
|
|
|
||
|
|
`GET /rt/v1/runtime.js`, `/react.js`, `/global.js`, `/embed.js`. Immutable, served
|
||
|
|
with a one-year `Cache-Control`, and CORS-open, since they are public assets loaded
|
||
|
|
cross-origin.
|
||
|
|
|
||
|
|
### Path-addressable forms
|
||
|
|
|
||
|
|
`/rt/v1/themes/{slug}/theme.css`, `/rt/v1/themes/{slug}/theme.json`,
|
||
|
|
`/rt/v1/themes/{slug}/embed`. For consumers that prefer REST shapes or a CDN
|
||
|
|
configured with path patterns rather than query strings.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Administration API
|
||
|
|
|
||
|
|
Requires a session cookie. Every mutation is audited.
|
||
|
|
|
||
|
|
There is no machine credential: API keys were removed. The runtime API is public by
|
||
|
|
design, and the administration API is for people, so a bearer token is not a
|
||
|
|
credential here and a request carrying one is treated as unauthenticated.
|
||
|
|
|
||
|
|
### Auth
|
||
|
|
|
||
|
|
| Method | Path | Purpose |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `GET` | `/api/v1/auth/me` | The current session. Always 200 |
|
||
|
|
| `GET` | `/api/v1/auth/config` | Client-visible configuration |
|
||
|
|
| `GET` | `/auth/login` | Start the OIDC flow |
|
||
|
|
| `GET` | `/auth/callback` | Complete it |
|
||
|
|
| `GET`/`POST` | `/auth/logout` | End the session and the provider session |
|
||
|
|
|
||
|
|
`/auth/me` always answers 200 with `authenticated: false` when there is no session,
|
||
|
|
because "not logged in yet" is the normal state on first load and not an error the UI
|
||
|
|
should handle as one.
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"authenticated": true,
|
||
|
|
"mode": "oidc",
|
||
|
|
"identity": {
|
||
|
|
"subject": "a1b2-c3d4",
|
||
|
|
"email": "designer@example.com",
|
||
|
|
"name": "A Designer",
|
||
|
|
"roles": ["editor"],
|
||
|
|
"method": "oidc"
|
||
|
|
},
|
||
|
|
"permissions": {
|
||
|
|
"read": true, "write": true, "publish": true,
|
||
|
|
"delete": false, "manageKeys": false, "manageUsers": false, "manageSettings": false
|
||
|
|
},
|
||
|
|
"preferences": {
|
||
|
|
"selectedTheme": "midnight",
|
||
|
|
"selectedAppearance": "dark"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`preferences` is present only when `authenticated` is true. It rides along with the
|
||
|
|
session so the UI can preselect a theme from the same round trip that establishes who
|
||
|
|
the caller is, rather than making a second request before it can paint.
|
||
|
|
|
||
|
|
### Preferences
|
||
|
|
|
||
|
|
Per-account interface preferences: which published theme the person has chosen, and
|
||
|
|
which of its two colour schemes to render. Any authenticated role may read and write
|
||
|
|
these, because a preference only affects what that one person sees and is keyed to
|
||
|
|
their own subject.
|
||
|
|
|
||
|
|
| Method | Path | Purpose |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `GET` | `/api/v1/me/preferences` | The caller's preference, or empty when none is stored |
|
||
|
|
| `PUT` | `/api/v1/me/preferences` | Record a preference and echo back what was stored |
|
||
|
|
| `DELETE` | `/api/v1/me/preferences` | Forget it, returning the interface defaults |
|
||
|
|
|
||
|
|
```http
|
||
|
|
PUT /api/v1/me/preferences
|
||
|
|
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
|
||
|
|
```
|
||
|
|
|
||
|
|
```json
|
||
|
|
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
|
||
|
|
```
|
||
|
|
|
||
|
|
Both fields empty means nothing has been chosen, and that is a valid state rather than
|
||
|
|
a missing one: the client applies its own default. An empty `selectedTheme` clears the
|
||
|
|
choice, and a `selectedAppearance` sent with it is dropped rather than stored, because
|
||
|
|
a scheme has nothing to select from.
|
||
|
|
|
||
|
|
`selectedTheme` names a **published** theme rather than copying it, so editing or
|
||
|
|
unpublishing that theme shows through everywhere without touching a stored value. Two
|
||
|
|
consequences are worth knowing:
|
||
|
|
|
||
|
|
- Saving a draft theme slug is a 400. Drafts are invisible to runtime consumers, so a
|
||
|
|
preference pointing at one would leave the UI rendering a theme it cannot load.
|
||
|
|
- A preference naming a theme that has since been deleted or archived **reads back as
|
||
|
|
no choice**, so a stale value cannot pin the interface to something that no longer
|
||
|
|
exists. The row is left alone; only the read is filtered.
|
||
|
|
|
||
|
|
The response is authoritative and should be applied in place of the request. It is the
|
||
|
|
only way a client learns that its value was filtered or cleared.
|
||
|
|
|
||
|
|
Preferences are deliberately **not** claims in the session token. The token is issued by
|
||
|
|
the identity provider and this service does not control it, so a choice made here has
|
||
|
|
to be read back from this API. They are also stored per account rather than per
|
||
|
|
browser, so they survive a re-login (`UpsertUser` rewrites the `users` projection on
|
||
|
|
every login) and follow the person to another device.
|
||
|
|
|
||
|
|
### Themes
|
||
|
|
|
||
|
|
| Method | Path | Role |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `GET` | `/api/v1/themes` | viewer |
|
||
|
|
| `GET` | `/api/v1/themes/{slug}` | viewer |
|
||
|
|
| `GET` | `/api/v1/themes/{slug}/export` | viewer |
|
||
|
|
| `POST` | `/api/v1/themes` | editor |
|
||
|
|
| `PATCH` | `/api/v1/themes/{slug}` | editor |
|
||
|
|
| `POST` | `/api/v1/themes/{slug}/publish` | editor |
|
||
|
|
| `POST` | `/api/v1/themes/{slug}/archive` | editor |
|
||
|
|
| `POST` | `/api/v1/themes/{slug}/duplicate` | editor |
|
||
|
|
| `POST` | `/api/v1/themes/import` | editor |
|
||
|
|
| `DELETE` | `/api/v1/themes/{slug}` | admin |
|
||
|
|
|
||
|
|
`GET /api/v1/themes` takes `q`, `status`, `system`, `tag`, `limit`, `offset`, `sort`
|
||
|
|
(`updated`, `created`, `name`, `slug`) and `order`. An unrecognised `sort` falls back
|
||
|
|
to the default rather than reaching SQL.
|
||
|
|
|
||
|
|
Listings omit token documents. They are the bulk of each row and a listing never needs
|
||
|
|
them.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -X POST https://themes.example.com/api/v1/themes \
|
||
|
|
-H 'Content-Type: application/json' \
|
||
|
|
-b cookies.txt \
|
||
|
|
-d '{
|
||
|
|
"name": "Acme Product",
|
||
|
|
"slug": "acme",
|
||
|
|
"description": "The default theme for customer-facing applications",
|
||
|
|
"status": "draft",
|
||
|
|
"tokens": { "color": { "semantic": { "primary": { "$type": "color", "$value": "#4f46e5" } } } }
|
||
|
|
}'
|
||
|
|
```
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -X PATCH https://themes.example.com/api/v1/themes/acme \
|
||
|
|
-H 'Content-Type: application/json' -b cookies.txt \
|
||
|
|
-d '{ "tokens": { … }, "baseVersion": 3, "message": "rebalanced the neutrals" }'
|
||
|
|
```
|
||
|
|
|
||
|
|
`baseVersion` gives optimistic concurrency. If the theme has moved on, the write is
|
||
|
|
rejected with 409 and a message naming both versions, rather than silently overwriting
|
||
|
|
someone else's work.
|
||
|
|
|
||
|
|
Publishing validates first:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"code": "validation_failed",
|
||
|
|
"message": "the theme cannot be published because its tokens are invalid",
|
||
|
|
"details": [
|
||
|
|
{ "field": "color.semantic.primary", "message": "not a valid colour: \"#nothex\"" }
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### History
|
||
|
|
|
||
|
|
| Method | Path | Role |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `GET` | `/api/v1/themes/{slug}/versions` | viewer |
|
||
|
|
| `GET` | `/api/v1/themes/{slug}/versions/{version}` | viewer |
|
||
|
|
| `GET` | `/api/v1/themes/{slug}/diff?from=1&to=2` | viewer |
|
||
|
|
| `POST` | `/api/v1/themes/{slug}/versions/{version}/rollback` | editor |
|
||
|
|
|
||
|
|
Rollback **appends**: the snapshot becomes the current state as a new version.
|
||
|
|
History is never rewritten, so an application deployed against version 7 can never
|
||
|
|
have version 7 taken away.
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"fromVersion": 6,
|
||
|
|
"toVersion": 7,
|
||
|
|
"added": ["color.semantic.tertiary"],
|
||
|
|
"removed": [],
|
||
|
|
"changed": [
|
||
|
|
{ "path": "color.semantic.primary", "from": "#4f46e5", "to": "#7c6bf5" }
|
||
|
|
],
|
||
|
|
"metaChanged": false
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Token tooling
|
||
|
|
|
||
|
|
Read-only analysis of a submitted document. Any authenticated user may call these.
|
||
|
|
|
||
|
|
| Method | Path | Purpose |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `POST` | `/api/v1/tokens/validate` | Validate without saving |
|
||
|
|
| `GET` | `/api/v1/tokens/schema` | The group and kind registry |
|
||
|
|
| `GET` | `/api/v1/tokens/palette?hue=&saturation=&lightness=&hueSpread=` | Generate a ramp |
|
||
|
|
| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | WCAG contrast |
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"valid": false,
|
||
|
|
"tokenCount": 214,
|
||
|
|
"issues": [
|
||
|
|
{ "path": "color.semantic.primary", "severity": "error", "message": "not a valid colour: \"#nothex\"" },
|
||
|
|
{ "path": "$appearances", "severity": "warning", "message": "no dark appearance overrides are defined", "hint": "add {\"$appearances\":{\"dark\":{…}}}" }
|
||
|
|
]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"foreground": "#7c6bf5",
|
||
|
|
"background": "#1a1d23",
|
||
|
|
"ratio": 6.42,
|
||
|
|
"rounded": 6.42,
|
||
|
|
"level": "aa",
|
||
|
|
"thresholds": { "aaLarge": 3, "aa": 4.5, "aaa": 7 },
|
||
|
|
"bestForeground": "#ffffff"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Administration
|
||
|
|
|
||
|
|
| Method | Path | Role |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `GET` | `/api/v1/stats` | viewer |
|
||
|
|
| `GET` | `/api/v1/admin/users` | admin |
|
||
|
|
| `GET` | `/api/v1/admin/audit` | admin |
|
||
|
|
| `GET` | `/api/v1/admin/settings` | admin |
|
||
|
|
| `PUT` | `/api/v1/admin/settings` | admin |
|
||
|
|
| `POST` | `/api/v1/admin/cache/rebuild` | admin |
|
||
|
|
|
||
|
|
Creating a key returns the plaintext **once**:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"key": { "id": "key_x7f2", "name": "CI publish", "prefix": "gb_a1b2c3d4", "scopes": ["themes:write"] },
|
||
|
|
"secret": "gb_a1b2c3d4_…",
|
||
|
|
"warning": "This secret is shown once and cannot be recovered. Store it now."
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Operational endpoints
|
||
|
|
|
||
|
|
| Path | Purpose |
|
||
|
|
| --- | --- |
|
||
|
|
| `/livez`, `/healthz` | Liveness, with build information on `/healthz` |
|
||
|
|
| `/readyz` | Readiness. Checks the database |
|
||
|
|
| `/metrics` | Prometheus, when `config.enableMetrics` |
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"status": "ready",
|
||
|
|
"checks": {
|
||
|
|
"database": { "status": "ok" },
|
||
|
|
"themes": { "status": "ok", "count": 42, "databaseBytes": 212992 },
|
||
|
|
"cache": { "status": "ok", "entries": 24 }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|