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`.