Anduril Catalog — API
Raw Markdown. Render via your editor of choice.
# Anduril Catalog — REST API (v1)
Multi-tenant, read-mostly product catalog.
Base URL: `https://catalog.anduril.studio/v1`
Locally: `http://localhost:3200/v1`
## Authentication
Two distinct auth schemes:
### Catalog endpoints (clients)
Header `X-API-Key: ak_<prefix>_<secret>`.
Tokens are issued by the agency cockpit (or via the `create-api-key` CLI on
the VPS). Each token belongs to a `client_slug` and carries a set of
`scopes` (currently only `catalog:read` is defined).
### Admin endpoints
Header `X-Admin-Key: <token>`.
This single shared token is `ADMIN_API_KEY` from the server `.env`.
Used to provision client API keys and read stats.
## Errors
```
HTTP 401
{ "error": { "code": "unauthorized", "message": "Invalid or missing API key" } }
```
Codes used: `unauthorized` (401), `forbidden` (403), `bad_request` (400),
`not_found` (404), `rate_limited` (429), `internal_error` (500),
`db_unavailable` (503).
## Rate limiting
Per-key fixed 60 s window. Default 60 req/min, configurable per key.
Response headers (catalog endpoints):
```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1730000060
```
When exceeded → `429` with `Retry-After` seconds.
## Pagination
List endpoints return:
```json
{
"data": [ ... ],
"pagination": { "page": 1, "pageSize": 24, "total": 7240, "totalPages": 302 }
}
```
Query params: `page` (≥1, default 1), `pageSize` (1..100, default 24).
## CORS
All origins allowed. Methods `GET, POST, DELETE, OPTIONS`. Allowed headers
`X-API-Key, X-Admin-Key, Content-Type`.
---
## Endpoints
### `GET /v1/products`
List + filter products.
Query params:
| name | type | notes |
| --- | --- | --- |
| `q` | string | French full-text on name + description |
| `editor` | slug | exact match |
| `designer` | slug | exact match |
| `mechanic` | slug,slug,… | comma-separated, OR semantics |
| `theme` | slug,slug,… | idem |
| `players_min` | int | filter games supporting at least N players |
| `players_max` | int | filter games supporting at most N players |
| `age_min` | int | filter `age_min >= N` |
| `bgg_rating_min` | float | filter `bgg_rating >= X` |
| `published` | `true`/`false` | default `true` |
| `page` | int | default 1 |
| `pageSize` | int | default 24, max 100 |
| `sort` | `name` / `-created_at` / `-bgg_rating` | default `name` |
Example:
```bash
curl -H "X-API-Key: $KEY" \
"https://catalog.anduril.studio/v1/products?editor=iello&pageSize=10&sort=-bgg_rating"
```
Response excerpt:
```json
{
"data": [
{
"id": "f3b1...",
"slug": "king-of-tokyo",
"name": "King of Tokyo",
"ean": "3760175510014",
"players": { "min": 2, "max": 6 },
"age_min": 8,
"duration": { "min": 30, "max": 30 },
"editor": { "slug": "iello", "name": "IELLO" },
"designer": { "slug": "richard-garfield", "name": "Richard Garfield" },
"mechanics": [{ "name": "Dice Rolling", "slug": "dice-rolling" }],
"themes": [{ "name": "Monster", "slug": "monster" }],
"images": [{ "position": 0, "url": "/v1/images/abc...", "alt": null }],
"bgg": { "id": 70323, "rating": 7.18, "rank": 412, "year": 2011, "..." : null }
}
],
"pagination": { "page": 1, "pageSize": 10, "total": 23, "totalPages": 3 }
}
```
### `GET /v1/products/:idOrSlug`
Accepts UUID or slug. `404` if not found.
```bash
curl -H "X-API-Key: $KEY" https://catalog.anduril.studio/v1/products/king-of-tokyo
```
### `GET /v1/editors` · `GET /v1/designers` · `GET /v1/mechanics` · `GET /v1/themes`
Each returns:
```json
{
"data": [
{ "name": "IELLO", "slug": "iello", "products_count": 23 }
]
}
```
### `GET /v1/images/:id`
Serves a `.webp` image directly from local storage.
Returns `404` if image record has no `local_path` or file is missing.
Headers:
```
Content-Type: image/webp
Cache-Control: public, max-age=31536000, immutable
```
### `GET /health` (public)
```json
{ "status": "ok", "db": "connected", "products_count": 7240 }
```
### `GET /v1/docs` (public)
This document, rendered as raw Markdown in a styled `<pre>`.
---
## Admin endpoints
### `POST /v1/admin/api-keys`
```bash
curl -X POST \
-H "X-Admin-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"Excalibur34 prod","client_slug":"excalibur34","rate_limit_per_min":120}' \
https://catalog.anduril.studio/v1/admin/api-keys
```
Response (token shown only once):
```json
{
"data": { "id": "...", "prefix": "a1b2c3d4", "label": "...", "...": null },
"token": "ak_a1b2c3d4_5ZxQ...",
"notice": "Store this token securely. It will not be shown again."
}
```
### `GET /v1/admin/api-keys`
Lists all keys (hash omitted).
### `DELETE /v1/admin/api-keys/:id`
Revokes (sets `revoked_at = now()`).
### `GET /v1/admin/stats`
Catalog totals, per-editor breakdown, last-24h request volume, top consumers.
---
## Obtaining an API key
For an agency operator on the VPS:
```bash
ssh root@vps.anduril.studio
cd /docker/anduril-catalog
docker compose exec app sh -lc \
"node node_modules/.bin/tsx scripts/create-api-key.ts \
--label 'Excalibur34 prod' --client-slug excalibur34"
```
The CLI prints the clear-text token exactly once. Save it in the
cockpit / client config immediately.
## Scopes
Currently defined: `catalog:read`. Future: `catalog:write`, `imports:write`.