go-bananas/docs/api.md

398 lines
12 KiB
Markdown
Raw Normal View History

2026-10-07 15:11:35 +02:00
# 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 }
}
}
```