# Spriteoven API Reference

> 🥐 The bakery is open: HTTP endpoints for AI-powered sprite generation,
> packing, and asset management. Aimed at indie game devs (Godot / Unity /
> custom engines) who want AI in their pipeline without a vendored editor.

If this is your first time, read the
[Getting started guide](./getting-started.md) first — that walks you from
sign-up to your first export. This document is the canonical reference for
every endpoint the server exposes, what it accepts, what it returns, and
how it fails.

**Document version:** `0.2.0` — Wave 3 Ciclo 7 closed (2026-05-09).
**Server version:** see `GET /api/sprite-lab/status` for the live build hash.

---

## Table of contents

1. [Authentication](#authentication)
2. [Conventions](#conventions)
3. [Sprite endpoints](#sprite-endpoints) — postprocess, pack, stitch
4. [Project endpoints](#project-endpoints) — projects CRUD
5. [Batch endpoints](#batch-endpoints) — batch + SSE stream
6. [Tilesets, iconpacks, tilemaps](#tilesets-iconpacks-tilemaps)
7. [Provider endpoints](#provider-endpoints) — NB2, GPT Image 2, Grok
8. [Validation](#validation) — similarity
9. [Recent AI Models Roadmap updates](#recent-ai-models-roadmap-updates)
10. [Library + Versions + Providers (Wave 3 GA)](#library--versions--providers-wave-3-ga)
11. [Auth & admin](#auth--admin)
12. [Error reference](#error-reference)
13. [Rate limiting](#rate-limiting)
14. [Changelog](#changelog)

---

## Authentication

Spriteoven supports two layers of authentication. They compose: most
production endpoints require **both** a Spriteoven user (Bearer JWT) and
an upstream provider key (BYOK header) when they call third-party
inference.

### 1. Spriteoven user (Bearer JWT)

Endpoints marked **Auth: Bearer JWT** require a Supabase-issued JSON Web
Token. Pass it on every request:

```
Authorization: Bearer <SUPABASE_JWT>
```

You obtain a JWT via the Spriteoven login flow (email + password, magic
link, or Google OAuth — see [Getting started](./getting-started.md#1-sign-up)).
JWTs are scoped to the user; row-level security (RLS) on the database
enforces ownership for every read/write.

Failures:

- **401** `unauthorized` `missing_token` — header absent.
- **401** `unauthorized` `invalid_token` — signature/expiry/format invalid.
- **401** `unauthorized` `verify_failed` — Supabase verification errored.

### 2. BYOK provider keys (per-request headers)

Endpoints that hit upstream model providers accept a "Bring Your Own Key"
header per request. Spriteoven **never persists** these keys — they are
read once from the request and dropped.

| Header | Provider | Endpoints |
| ------ | -------- | --------- |
| `X-OpenAI-Key` | OpenAI (GPT Image 2) | `/api/providers/gpt-image-2/generate`, `/api/byok/validate` |
| `X-XAI-Key` | xAI (Grok Image) | `/api/sprite-lab/providers/grok/generate` |
| `X-Gemini-Key` | Google Gemini (Nano Banana 2) | `/api/sprite-lab/nb2`, `/api/generate`, `/api/sprite-lab/tileset/generate` (when model=nb2) |

If a header is omitted, the server falls back to the corresponding
environment variable (`OPENAI_API_KEY`, `XAI_API_KEY`, `GEMINI_API_KEY`).
If neither is present, the endpoint returns **503** `byok_required` /
`missing_api_key`.

> 🔒 **Never logged.** Provider keys never enter server logs, error
> bodies, telemetry, or asset metadata. If you find one leaking, file an
> incident — that is a security bug.

---

## Conventions

### Base URL

All Spriteoven business endpoints live under `/api/sprite-lab/*` (and a
small set under `/api/*` for cross-cutting concerns: auth, BYOK
validation, providers). On the hosted instance the base is
`https://spriteoven.com`. On a local dev server it's
`http://localhost:3000` by default.

### Content type

All JSON endpoints take `Content-Type: application/json` and return
`application/json; charset=utf-8`.

The two exceptions:

- `/api/sprite-lab/tilemap/export` returns either `application/xml` (TMX)
  or `text/plain` (TSCN) depending on the `format` field.
- `/api/sprite-lab/batch/:batch_id/stream` returns
  `text/event-stream` (Server-Sent Events).

### Image payloads

Every image in/out is **base64-encoded PNG** (without a `data:` prefix —
the server tolerates the prefix and strips it, but the canonical form is
raw base64). Field names ending in `Base64` or `_b64` follow this
convention. Sheet outputs are returned in the response body, not as file
attachments — call sites decode and write to disk as needed.

### Error envelope

Most JSON endpoints return errors using one of two shapes:

```json
{ "ok": false, "error": "ERROR_CODE", "message": "human-readable detail" }
```

```json
{ "error": "error_code", "message": "human-readable detail" }
```

The `ok: false` shape is the dominant one (Sprite Lab endpoints + auth
guards); the bare `error` shape is used by a smaller set of legacy /
cross-cutting endpoints. Both forms always carry an `error` string code
and a `message`. Some errors include extra fields (e.g. `errors[]` for
multi-field validation, `valid` for enum hints, `retry_after` for rate
limits) — those are documented per endpoint.

Status codes follow standard semantics: `400` invalid input, `401`
unauthenticated, `403` not your resource, `404` not found, `410` deleted,
`429` rate-limited, `500` server bug, `503` upstream/config not ready.

---

## Sprite endpoints

### POST /api/sprite-lab/postprocess

Runs the canonical post-processing pipeline on a generated PNG: chroma
key (lime green → transparent), bbox crop / autopad, optional palette
quantization, and slice into N frames according to a strategy (auto,
forced grid, or hint-driven).

**Auth:** none.

**Request body:**

```json
{
  "imageBase64": "<base64 PNG>",
  "targetGrid": 64,
  "keyColor": "auto",
  "sliceHint": "auto",
  "expectedRows": null,
  "expectedCols": null,
  "hueTolerance": 22,
  "satTolerance": 0.4,
  "valTolerance": 0.4,
  "islandRemovalMinArea": 16,
  "cleanAlphaRGB": true,
  "targetPalette": {
    "algorithm": "floyd-steinberg",
    "colors": ["#000000", "#ffffff", "#ff0080", "#00ff80"],
    "preserveAlpha": true
  }
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `imageBase64` | string | **Required.** Base64 PNG. |
| `targetGrid` | number \| `"auto"` | Frame side length in pixels. Default `64`. |
| `keyColor` | `"auto"` \| `{h,s,v}` | Chroma key color. `auto` ⇒ default lime green. |
| `sliceHint` | string | `"auto"`, `"square"`, `"horizontal"`, `"vertical"`. |
| `expectedRows` / `expectedCols` | int \| null | Force grid dims. |
| `hueTolerance` / `satTolerance` / `valTolerance` | number | Chroma tolerance overrides. |
| `islandRemovalMinArea` | int | Min connected-component area to keep. `0` disables. |
| `cleanAlphaRGB` | bool | Zero RGB on alpha=0 pixels (PNG-size win). |
| `targetPalette` | object \| null | C7-01 palette quantizer. `algorithm` ∈ {`nearest`, `floyd-steinberg`, `atkinson`, `bayer4x4`}. `colors[]` ≥ 4 hex strings. |

**Response (200):**

```json
{
  "ok": true,
  "transparentPngBase64": "<base64 PNG>",
  "boundingBox": { "x": 12, "y": 8, "width": 240, "height": 248 },
  "strategy": {
    "type": "uniform-grid",
    "rows": 4, "cols": 4,
    "cellW": 64, "cellH": 64,
    "frameCount": 16,
    "forced": false,
    "forcedTo": null,
    "overrideFromCaller": false
  },
  "frames": [
    {
      "frameIndex": 0,
      "row": 0, "col": 0,
      "pngBase64": "<base64 PNG>",
      "paddedSize": [64, 64],
      "sourceRegion": {"x": 0, "y": 0, "width": 64, "height": 64},
      "placement": {"row": 0, "col": 0}
    }
  ],
  "metadata": { "...": "pipeline metadata, see implementation" },
  "palette": {
    "applied": true,
    "algorithm": "floyd-steinberg",
    "palette_size": 4,
    "preserveAlpha": true
  }
}
```

**Errors:**

| Status | Code | When |
| ------ | ---- | ---- |
| 400 | `EMPTY_IMAGE` | `imageBase64` missing. |
| 400 | `BAD_BASE64` | `imageBase64` cannot be decoded. |
| 400 | `PALETTE_BAD_SHAPE` / `PALETTE_BAD_ALGORITHM` / `PALETTE_TOO_SMALL` / `PALETTE_BAD_COLOR` | Palette validation failure. Body includes `valid_algorithms`. |
| 500 | `POSTPROCESS_FAILED` | Pipeline raised. Body includes `stack`. |

**Example (curl):**

```bash
curl -X POST https://spriteoven.com/api/sprite-lab/postprocess \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg img "$(base64 -w0 sheet.png)" \
        '{imageBase64:$img, targetGrid:64, sliceHint:"auto"}')"
```

**Example (JS fetch):**

```js
const png = await fs.promises.readFile("sheet.png");
const r = await fetch("/api/sprite-lab/postprocess", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    imageBase64: png.toString("base64"),
    targetGrid: 64,
    sliceHint: "auto",
  }),
});
const { transparentPngBase64, frames, strategy } = await r.json();
```

---

### POST /api/sprite-lab/pack

Single-engine sprite-sheet packer. Takes N base64 PNG frames, packs them
with `maxrects-packer` (custom engine), and returns one of two shapes
depending on whether `outputs[]` was supplied (DEC-SF-022 multi-output
path, [DEC-SF-018](../DECISIONES-SPRITEFORGE.md) single-engine).

**Auth:** none.

**Request body:**

```json
{
  "engine": "auto",
  "frames": ["<base64 PNG>", "<base64 PNG>"],
  "packOptions": {
    "maxWidth": 2048,
    "maxHeight": 2048,
    "padding": 1,
    "trim": false,
    "allowRotation": false,
    "extrude": 0
  },
  "tresOptions": {
    "resourcePath": "res://sprites/",
    "pngFilename": "knight.png",
    "resourceName": "knight",
    "animations": [
      { "name": "idle", "frames": [0, 1, 2, 3], "loop": true, "speed": 8 }
    ]
  },
  "outputs": ["png-sliced", "tres", "godot-bundle"],
  "targetPalette": null,
  "assetMetadata": {
    "type": "character",
    "asset_id": "knight-001",
    "provider": "nb2"
  }
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `engine` | string | `"auto"` or `"custom"`. `"aseprite"` returns 410 Gone (DEC-SF-018). |
| `frames` | string[] | **Required.** Base64 PNGs. Non-empty. |
| `packOptions.extrude` | int 0..8 | Border-replication padding. |
| `outputs[]` | string[] | Optional multi-output. Values: `"png-sliced"`, `"tres"`, `"aseprite-bundle"`, `"godot-bundle"`. |
| `targetPalette` | object \| null | Same shape as `/postprocess`. |
| `assetMetadata` | object | Optional ride-along context for `asset-metadata.json` in bundles. |

**Response — legacy shape (no `outputs[]`):**

```json
{
  "ok": true,
  "engine": "custom",
  "sheetPngBase64": "<base64 PNG>",
  "dimensions": { "width": 256, "height": 256 },
  "tresContent": "[gd_resource type=\"SpriteFrames\" ...]",
  "animationsCount": 4,
  "layout": [{ "frame": 0, "x": 0, "y": 0, "w": 64, "h": 64, "rot": false }],
  "extrude": 0,
  "elapsedMs": 142,
  "packMs": 12,
  "palette": { "applied": false }
}
```

**Response — multi-output shape (`outputs[]` supplied):**

```json
{
  "ok": true,
  "engine": "custom",
  "packMs": 14,
  "sheet": { "width": 256, "height": 256 },
  "outputs": {
    "png-sliced": { "mime": "application/zip", "filename": "frames.zip", "data": "<base64>" },
    "tres": { "mime": "text/plain", "filename": "knight.tres", "data": "..." },
    "godot-bundle": { "mime": "multi", "sheet_data": "<base64>", "tres_data": "..." }
  }
}
```

**Errors:**

| Status | Code | When |
| ------ | ---- | ---- |
| 400 | `INVALID_ENGINE` | `engine` ≠ auto/custom/aseprite. |
| 400 | `EMPTY_OUTPUTS_ARRAY` / `INVALID_OUTPUT` | Bad `outputs[]`. |
| 400 | `EMPTY_FRAMES` | `frames` missing/empty. |
| 400 | `BAD_FRAME_BASE64` | Frame not decodable. |
| 400 | `INVALID_EXTRUDE` | `packOptions.extrude` ∉ [0,8]. |
| 400 | `PALETTE_*` | Palette validation. Includes `valid_algorithms`. |
| 410 | `deprecated` | `engine=aseprite` (since Wave 5 / DEC-SF-018). |
| 500 | `MULTI_OUTPUT_FAILED` | Multi-output bundle assembly failed. Body includes `failed_outputs[]`. |
| 500 | `PACK_FAILED` | Generic pack error. Body includes `stack`. |

---

### POST /api/sprite-lab/stitch

Composes N single-direction PNG strips (each 1×cols) into a uniform
rows×cols sheet. Sits PRE-pipeline; the stitched buffer feeds `/pack`
unchanged. API-only at the moment ([DEC-SF-021](../DECISIONES-SPRITEFORGE.md)
formalization pending).

**Auth:** none.

**Request body:**

```json
{
  "inputs": [
    { "name": "down", "imageBase64": "<base64 PNG>" },
    { "name": "left", "imageBase64": "<base64 PNG>" },
    { "name": "right", "imageBase64": "<base64 PNG>" },
    { "name": "up", "imageBase64": "<base64 PNG>" }
  ],
  "stitchOptions": {
    "rows": 4,
    "cols": 4,
    "rowOrder": ["down", "left", "right", "up"]
  }
}
```

**Response (200):**

```json
{
  "ok": true,
  "stitched": {
    "filename": "stitched-4x4.png",
    "data": "<base64 PNG>",
    "width": 256,
    "height": 256,
    "rows": 4,
    "cols": 4,
    "frame_size": [64, 64],
    "cellWidthExact": true,
    "rowOrder": ["down", "left", "right", "up"]
  }
}
```

**Errors (all 400 unless noted):**

`STITCH_EMPTY_INPUTS`, `STITCH_BAD_INPUT`, `STITCH_ROWS_MISMATCH`,
`STITCH_ROW_ORDER_LENGTH`, `STITCH_ROW_ORDER_AMBIGUOUS`,
`STITCH_ROW_ORDER_NAME_UNKNOWN`, `STITCH_WIDTH_MISMATCH`,
`STITCH_HEIGHT_MISMATCH`, `STITCH_WIDTH_NOT_DIVISIBLE`. **500**
`STITCH_FAILED` for unexpected.

---

## Project endpoints

CRUD over the `projects` table. All five routes require `Bearer JWT`,
and RLS enforces ownership: cross-user reads return **404** (not 403, to
avoid leaking existence). Only `DELETE` cascades to `assets` /
`asset_versions` / `jobs`.

### POST /api/sprite-lab/projects

**Auth:** Bearer JWT.

**Request:** `{ "name": "My game", "config_json": { "...": "..." } }`

**Response (200):** `{ "ok": true, "project": { "id":"...", "user_id":"...", "name":"...", "config_json":{...}, "created_at":"...", "updated_at":"..." } }`

**Errors:** 400 `PROJECT_NAME_REQUIRED` / `PROJECT_NAME_TOO_LONG` (>200) /
`PROJECT_INSERT_FAILED`. 503 `SUPABASE_NOT_CONFIGURED`.

### GET /api/sprite-lab/projects

**Auth:** Bearer JWT. **Response:** `{ "ok": true, "projects": [...] }`.
Ordered by `updated_at desc`. **500** `PROJECT_LIST_FAILED`.

### GET /api/sprite-lab/projects/:id

**Auth:** Bearer JWT. **Response:** `{ "ok": true, "project": {...} }`.
**404** `PROJECT_NOT_FOUND` (also for cross-user: RLS hides existence).

### PATCH /api/sprite-lab/projects/:id

**Auth:** Bearer JWT. **Request:** `{ "name": "...", "config_json": {...} }`
(both optional, at least one required).

**Errors:** 400 `PROJECT_PATCH_EMPTY` / `PROJECT_NAME_INVALID` /
`PROJECT_CONFIG_INVALID` / `PROJECT_NAME_TOO_LONG`. 404 `PROJECT_NOT_FOUND`.

### DELETE /api/sprite-lab/projects/:id

**Auth:** Bearer JWT. **Response:** `{ "ok": true, "deleted": {...} }`.
Cascades through FK `ON DELETE CASCADE`. **404** `PROJECT_NOT_FOUND`.

---

## Batch endpoints

In-memory batch executor for long-running pipelines. Each job uses the
same code path as the corresponding individual endpoint, so validation
rules carry over verbatim. `result_summary` is intentionally compact —
for full base64 outputs, call the individual endpoint.

### POST /api/sprite-lab/batch

**Auth:** Bearer JWT.

**Request body:**

```json
{
  "concurrency": 3,
  "jobs": [
    { "client_job_id": "myref-001", "type": "stitch", "params": { "...": "..." } },
    { "client_job_id": "myref-002", "type": "postprocess", "params": { "...": "..." } },
    { "client_job_id": "myref-003", "type": "pack", "params": { "...": "..." } }
  ]
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `concurrency` | int 1..5 | Default 3, max 5. |
| `jobs` | object[] | Max 100 entries. |
| `jobs[].type` | string | One of `"stitch"`, `"postprocess"`, `"pack"`, `"iconpack"` (NYI), `"tileset"` (NYI). |
| `jobs[].client_job_id` | string | Optional caller-side correlation id. |
| `jobs[].params` | object | Same body the individual endpoint accepts. |

**Response (200):**

```json
{
  "ok": true,
  "batch_id": "01H...XYZ",
  "jobs_count": 3,
  "stream_url": "/api/sprite-lab/batch/01H...XYZ/stream"
}
```

**Errors:** 400 `BATCH_BAD_REQUEST` / `BATCH_EMPTY_JOBS` /
`BATCH_TOO_MANY_JOBS` (>100) / `BATCH_BAD_JOB` / `BATCH_BAD_JOB_TYPE` /
`BATCH_BAD_CLIENT_JOB_ID` / `BATCH_BAD_CONCURRENCY`. Body includes
`valid_job_types`.

### GET /api/sprite-lab/batch/:batch_id/stream

**Auth:** Bearer JWT. **Returns:** `text/event-stream`. The stream is
ownership-gated: only the user who created the batch can subscribe.

**SSE events:**

```
event: job_started
data: {"job_id":"...","client_job_id":"myref-001","type":"stitch","started_at":"2026-..."}

event: job_progress
data: {"job_id":"...","percent":42,"stage":"chroma"}

event: job_completed
data: {"job_id":"...","result":{...},"elapsedMs":143}

event: job_failed
data: {"job_id":"...","error":"...","code":"..."}

event: batch_completed
data: {"batch_id":"...","stats":{...}}
```

**Errors:** 404 `BATCH_NOT_FOUND`. 403 `BATCH_FORBIDDEN` (caller is not
the batch owner).

---

## Tilesets, iconpacks, tilemaps

### POST /api/sprite-lab/tileset/generate

Generates a tileset (NxN grid of tiles) with chroma key + autotile rules
+ seamless-edge validation, packed into a `tileset-bundle` ZIP.

**Auth:** Bearer JWT. If `project_id` is supplied, ownership is also
asserted (cross-user → 403 / 404 depending on RLS path).

**Request body:**

```json
{
  "tile_size": 32,
  "tile_count": 16,
  "biome": "forest moss with stone path",
  "style_preset": "pixel-art",
  "seamless_edges": true,
  "auto_tile_rules": "blob",
  "model": "nb2",
  "target_palette": null,
  "project_id": "uuid-or-null"
}
```

| Field | Allowed values |
| ----- | -------------- |
| `tile_size` | `16`, `24`, `32`, `48`, `64` |
| `tile_count` | `4`, `8`, `16`, `24`, `32` |
| `biome` | free-text, ≤ 200 chars |
| `style_preset` | `"pixel-art"`, `"stylized-vector"`, `"outlined-flat"` |
| `auto_tile_rules` | `"blob"`, `"wang"`, `"none"` |
| `model` | `"gpt-image-2"`, `"nb2"` |

**Response (200):**

```json
{
  "ok": true,
  "bundle": { "format": "tileset-bundle", "zipBase64": "<base64 ZIP>" },
  "sheet": { "base64": "<base64 PNG>", "width": 128, "height": 128 },
  "grid": { "rows": 4, "cols": 4, "tile_size": 32, "tile_count": 16 },
  "auto_tile": { "rule": "blob", "expected_tiles": 47, "warnings": [] },
  "seamless": { "ok": true, "avg_mismatch_pct": 1.2, "warning": false, "warnings": [] },
  "provider": { "model": "models/imagen-3.0-fast-generate-001", "elapsedMs": 4280, "estimatedCostUsd": 0.039 },
  "project_id": "uuid-or-null"
}
```

**Errors:** 400 `INVALID_TILESET_PARAMS` (body includes `errors[]` and
`valid` enum hints); 400 `NO_API_KEY`; 503 `GPT_IMAGE_2_API_NOT_GA`; 429
`RATE_LIMITED`; 500 `AI_PROVIDER_ERROR` / `TILESET_GENERATE_FAILED`.

### POST /api/sprite-lab/iconpack/generate

Generates an iconpack (N icons of a category in coherent style). Same
shape as `/tileset/generate` minus seamless edges; adds bbox-consistency
check.

**Auth:** Bearer JWT (+ optional `project_id` ownership).

**Request body:**

```json
{
  "category": "weapons",
  "count": 16,
  "icon_size": 32,
  "grid_layout": "auto",
  "theme": "fantasy weapons, ornate",
  "style_preset": "pixel-art",
  "model": "nb2",
  "target_palette": null,
  "seed_examples": [],
  "project_id": "uuid-or-null"
}
```

| Field | Allowed values |
| ----- | -------------- |
| `category` | `weapons`, `shields`, `consumables`, `ui`, `armor`, `accessories`, `tools`, `magical` |
| `count` | `16`, `24`, `32`, `48`, `64` |
| `icon_size` | `16`, `24`, `32`, `48`, `64` |
| `grid_layout` | `"auto"` or `"RxC"` string |
| `style_preset` | `"pixel-art"`, `"stylized-vector"`, `"outlined-flat"` |
| `theme` | free-text, ≤ 200 chars |
| `seed_examples[]` | up to 16 base64 PNG references |
| `model` | `"gpt-image-2"`, `"nb2"` |

**Response (200):** mirrors `/tileset/generate` with `consistency` instead
of `seamless`, and `category` / `theme` echoed at root.

**Errors:** 400 `INVALID_ICONPACK_PARAMS` (with `errors[]` + `valid`); rest
identical to tileset.

### POST /api/sprite-lab/tilemap/save

**Auth:** Bearer JWT + project ownership (cross-user → 403
`TILEMAP_FORBIDDEN`). Persists a tilemap as a row in `assets`
(`type=tilemap`).

**Request body:**

```json
{
  "project_id": "uuid",
  "name": "level-01",
  "tileset_id": "uuid-or-null",
  "grid_size": [32, 32],
  "layers": [{ "name":"ground","z":0 }],
  "cells": [{ "layer":0,"x":0,"y":0,"tile":3 }]
}
```

**Response:** `{ "ok": true, "asset": { "...row...": "..." } }`.

**Errors:** 400 `PROJECT_ID_REQUIRED` / `NAME_REQUIRED`; 403
`TILEMAP_FORBIDDEN`; 500 `TILEMAP_SAVE_FAILED`; 503
`SUPABASE_NOT_CONFIGURED`.

### POST /api/sprite-lab/tilemap/export

**Auth:** none. Download-only serializer. **Body:**
`{ "tilemap_data": {...}, "format": "tmx" | "tscn" }`. Returns
`application/xml` (TMX) or `text/plain` (TSCN). **Errors:** 400
`TILEMAP_INVALID` / `TILEMAP_FORMAT_INVALID`; 500 `TILEMAP_EXPORT_FAILED`.

---

## Provider endpoints

These are thin pass-throughs to upstream image providers. They exist so
clients can experiment with providers directly without going through the
canonicalize / pack pipeline. **Production callers** generally use
`/postprocess` + `/pack` after a single provider call.

### POST /api/sprite-lab/nb2

Google Gemini Nano Banana 2 (image generation).

**Auth:** none, but **requires** a Gemini key (`X-Gemini-Key` header or
`GEMINI_API_KEY` env).

**Request:** `{ "prompt": "...", "referenceImageBase64": "...", "label": "...", "aspectRatio": "1:1" }`

**Response (200):** `{ "ok": true, "imageBase64": "...", "mime": "image/png", "dimensions": {"width":1024,"height":1024}, "elapsedMs": 4200, "estimatedCostUsd": 0.039, "model": "...", "savedTo": "..." }`

**Errors:** 400 `EMPTY_PROMPT` / `NO_API_KEY`; 429 `RATE_LIMITED` (body
includes `suggestedBackoffMs`); 500 `NB2_API_ERROR` / `INTERNAL`.

### POST /api/sprite-lab/gpt-image-2

OpenAI GPT Image 2 (Sprite Lab variant). Same body / response as `/nb2`.

**Auth:** none (BYOK via env). **Errors:** 400 `EMPTY_PROMPT`; 503
`GPT_IMAGE_2_API_NOT_GA` (body includes `fallbackPath` and
`detectionReason`); 429/500 as above.

### POST /api/providers/gpt-image-2/generate

Production GPT Image 2 endpoint. Note the path differs from
`/api/sprite-lab/gpt-image-2`: this one **accepts BYOK via header** and
returns a dedicated multi-image response shape.

**Auth:** none. **BYOK:** `X-OpenAI-Key` header (or `OPENAI_API_KEY` env).

**Request:** `{ "prompt": "...", "refImages": [...], "size": "1024x1024", "quality": "high", "model": "gpt-image-2", "endpoint": "..." }`

**Response (200):** `{ "sheet": "<base64 PNG>", "meta": { "...": "..." } }`

**Errors:** 503 `missing_api_key`; provider-specific
`GptImage2Error.code` (e.g. `quota_exceeded`, `model_not_available`)
surfaced with the upstream status; 500 `internal`.

### POST /api/sprite-lab/providers/grok/generate

xAI Grok Image (Aurora MoE), pinned to `grok-imagine-image-quality`.

**Auth:** Bearer JWT. **BYOK:** `X-XAI-Key` (or `XAI_API_KEY` env).

**Request:** `{ "prompt": "...", "refImages": [...], "size": "1024x1024", "quality": "high" }`

**Response (200):** `{ "ok": true, "sheet": "<base64 PNG>", "meta": {...} }`

**Errors:** 400 `EMPTY_PROMPT`; 503 `byok_required`; 429 with
`retry_after`; 500 `INTERNAL` / provider-specific (`GrokImageError.code`).

---

## Validation

### POST /api/sprite-lab/validate-similarity

Compares two PNG assets and returns a similarity score. Used to verify
identity preservation across providers, style consistency in iconpacks,
and dedup in libraries.

**Auth:** Bearer JWT.

**Request body:**

```json
{
  "asset_a": "<base64 PNG or {imageBase64: ...}>",
  "asset_b": "<base64 PNG or {imageBase64: ...}>",
  "method": "phash"
}
```

`method` ∈ `"phash"` (default), `"ssim"`, `"perceptual"`.

**Response (200):** depends on method; always includes a `score` field
in `[0,1]` and method-specific diagnostics.

**Errors:** 400 `bad_request` (`asset_a_and_asset_b_required`,
`unknown_method:<m>`, `asset_decode_failed`).

---

## Recent AI Models Roadmap updates

> **Wave 4 update (2026-05-09)** — Wave 3 closed and Wave 4 opened only
> 1 calendar day apart, so the scan is short. **No catalog changes**:
> NB2 + gpt-image-2 + Grok remain Tier 0/1, Imagen 4 family stays out
> of the dropdown pending DEC-SF-030 formal sign-off, DALL-E 2/3 sunset
> is **T-3 days** (2026-05-12). New radar entry: **Qwen-Image-2**
> (Alibaba, Apache 2, April 2026) opens as a Tier 2 cold candidate —
> open-weights so it does not fit the BYOK model directly; possible
> path is via a SaaS aggregator (Replicate / Fal) in Ciclo 8+. PixelLab
> "Vibe Coding" MCP traction stays tibia (no hockey-stick after a
> month) so the Spriteoven-own MCP server item drops to Ciclo 10+ in
> the backlog. Full report: `EVIDENCE/cycle7/ai-models-scan/wave-4.md`.

A short echo of the Wave 3 model-landscape scan (full report in
`EVIDENCE/cycle7/ai-models-scan/wave-3.md`, lookback ~30 days).

- **Imagen 4 family (Google, Gemini API)** — POST/wire endpoint live as
  `/api/sprite-lab/providers/imagen4/generate` (BYOK, Tier 1 paid). Wave 3
  smoke real surfaced **two distinct outcomes**: Imagen 4 Fast ignores
  sprite-sheet prompt structure (returns 1-frame painterly outputs +
  wrong background) → DROPPED Ciclo 7. Imagen 4 Standard PASSES quality
  but $0.04/img is 2× Grok's $0.02/img with no diferencial → reclassified
  as **Tier 2 candidate Ciclo 8+** (not Tier 1). Imagen 4 Ultra DROPPED
  for fixed (premium pricing without test plan that justifies it). See
  **DEC-SF-030** (formalized at Wave 3 closure).
- **Grok Image (xAI Aurora MoE)** — provider dropdown wired in Sprite
  Lab UI Wave 3, plus BYOK form + key-validation endpoint
  (`/providers/grok/test-key`). 3/3 provider-switching tests PASS
  (`scripts/cycle7/test-c7-h2-provider-switching.mjs`). DEC-SF-029 wireup
  complete.
- **DALL-E 2/3 sunset 2026-05-12** — confirmed; Spriteoven already
  on `gpt-image-2-2026-04-21` so no migration needed (DEC-SF-012
  vindicated).
- **PixelLab MCP server "Vibe Coding"** — competitor watch HIGH alert.
  AI coding assistants (Claude / Cursor / Windsurf) can now invoke
  PixelLab's pixel-art generation directly. Reviewed for Spriteoven MCP
  surface in Ciclo 9-10 backlog.
- **Retro Diffusion ControlNet expansion (April)** + **Prompt Guidance
  (May)** — Tier 2 candidate elevated to MEDIUM priority for Ciclo 8.

---

## Library + Versions + Providers (Wave 3 GA)

This section consolidates the 15 endpoints that landed across Tracks G
(Library), H (Providers extended), and I (Versions) during Wave 3
Ciclo 7. They are GA. Auth is **Bearer JWT** on every endpoint, with
RLS enforcing transitive ownership through `assets → projects →
user_id` (cross-user reads return 404 `ASSET_NOT_FOUND` to avoid
existence leak; cross-user mutations return 403 `ASSET_FORBIDDEN` /
`BULK_FORBIDDEN` once an attempt is made).

### Library (Track G — C7-11 + C7-12)

#### GET /api/sprite-lab/assets

List the caller's assets. RLS-scoped; soft-deleted rows excluded.

**Auth:** Bearer JWT.

**Query parameters:**

- `project_id` (optional) — filter to one project.
- `tags` (optional) — comma-separated; OR-overlap filter (returns rows
  whose `tags` intersect the supplied set).
- `q` (optional) — free-text search; tokenized on whitespace and
  joined with ` | ` against the `search_vector` tsvector column
  (config `simple`).
- `limit` (default 50, max 200), `offset` (default 0) — pagination.

**Response (200):**

```json
{
  "ok": true,
  "assets": [{ "id": "<uuid>", "project_id": "<uuid>", "asset_id": "...",
    "name": "...", "tags": ["walk","hero"], "metadata_json": {...},
    "created_at": "...", "deleted_at": null }],
  "limit": 50, "offset": 0
}
```

**Errors:** 401 `unauthorized`; 500 `ASSET_LIST_FAILED`.

```bash
curl -H "Authorization: Bearer $JWT" \
  "$BASE/api/sprite-lab/assets?project_id=$PID&tags=walk,hero&limit=20"
```

#### GET /api/sprite-lab/assets/search

Same as `?q=...` on the list endpoint, but stricter: requires `q`,
caps `limit` at 100, default 30.

**Errors:** 400 `QUERY_REQUIRED` (no `q`); 500 `ASSET_SEARCH_FAILED`.

#### GET /api/sprite-lab/assets/:id

Single-asset read. Soft-deleted rows return 404.

**Response (200):** `{ "ok": true, "asset": {...} }`.

**Errors:** 400 `ASSET_ID_REQUIRED`; 404 `ASSET_NOT_FOUND` (missing /
RLS-hidden / soft-deleted); 500 `ASSET_GET_FAILED`.

#### PATCH /api/sprite-lab/assets/:id

Update `name` and/or `tags`. Bytes / metadata_json are not editable
through this endpoint (use a new version via `/restore` for bytes).

**Request body:** any subset of `{ "name": "...", "tags": ["..."] }`.

**Response (200):** `{ "ok": true, "asset": {...} }`.

**Errors:** 400 `ASSET_NAME_INVALID` / `ASSET_NAME_TOO_LONG` /
`ASSET_TAGS_INVALID` / `ASSET_PATCH_EMPTY`; 404 `ASSET_NOT_FOUND`;
500 `ASSET_UPDATE_FAILED`.

#### DELETE /api/sprite-lab/assets/:id

Soft delete (sets `deleted_at = now()`). Versions are NOT cascade-
deleted at this layer — they become inaccessible until you restore
the asset (deleted_at=NULL) via a Wave 4 endpoint.

**Response (200):** `{ "ok": true, "deleted": { "id": "...",
"deleted_at": "..." } }`.

**Errors:** 404 `ASSET_NOT_FOUND`; 500 `ASSET_DELETE_FAILED`.

#### POST /api/sprite-lab/assets/bulk-delete

Batch soft-delete. **All-or-nothing** ownership: any cross-user / missing
id aborts the entire batch with 403 `BULK_FORBIDDEN` and a `missing_count`
hint — no partial deletes.

**Request body:** `{ "asset_ids": ["uuid1", "uuid2", ...] }` (max 200).

**Response (200):**
`{ "ok": true, "deleted_count": 5, "deleted_ids": ["..."] }`.

**Errors:** 400 `BULK_EMPTY` / `BULK_TOO_MANY` (>200) / `BULK_BAD_ID`;
403 `BULK_FORBIDDEN` (with `missing_count`); 500 `BULK_LOOKUP_FAILED` /
`BULK_DELETE_FAILED`.

#### POST /api/sprite-lab/assets/bulk-tag

Batch add/remove tags. Read-modify-write per row (tag set diff).

**Request body:**
```json
{
  "asset_ids": ["..."],
  "tags_add":    ["new-tag"],
  "tags_remove": ["old-tag"]
}
```

At least one of `tags_add` / `tags_remove` must be non-empty.

**Response (200):** `{ "ok": true, "updated_count": 12 }`.

**Errors:** 400 `BULK_TAG_NOOP` (both empty); same all-or-nothing
ownership errors as `bulk-delete`; 500 `BULK_TAG_LOOKUP_FAILED` /
`BULK_TAG_UPDATE_FAILED` (carries `partial_updated` count if mid-loop).

#### POST /api/sprite-lab/assets/bulk-export

Spawns a single-job **batch** (subscribable via the existing
`/api/sprite-lab/batch/:batch_id/stream` SSE) that builds a metadata-only
ZIP (one JSON entry per asset + `manifest.json`). Storage-blob
dereferencing lands when Track I storage pipeline closes (Wave 4+).

**Request body:** `{ "asset_ids": ["..."] }` (max 200).

**Response (200):**
```json
{
  "ok": true,
  "job_id":   "<batch_id>",
  "batch_id": "<batch_id>",
  "asset_count": 17,
  "stream_url": "/api/sprite-lab/batch/<batch_id>/stream"
}
```

Subscribe to the SSE stream for live progress + final result.

**Errors:** same as `bulk-delete` for ownership / validation;
500 `BULK_EXPORT_LOOKUP_FAILED` / `BULK_EXPORT_FAILED`.

### Versions (Track I — C7-14)

Versions are immutable rows on `asset_versions` (FK to `assets.id`,
unique on `(asset_id, version_number)`). A Postgres trigger
(`enforce_version_retention`) keeps **max 10 unpinned versions** per
asset; pinned versions are exempt. All mutations route through
RLS-scoped clients.

> ℹ️ Cross-user access to *any* version endpoint converts the 404
> ownership signal into **403 `ASSET_FORBIDDEN`** (matching the
> `tilemap/save` precedent). Within an owned asset, a missing /
> wrong-version-id returns **404 `VERSION_NOT_FOUND`**.

#### GET /api/sprite-lab/assets/:id/versions

List versions ordered by `created_at DESC`.

**Response (200):**
`{ "ok": true, "versions": [{ "id": "...", "version_number": 7,
"pinned": false, "metadata_json": {...}, "bytes": 12345,
"created_at": "..." }, ...] }`.

#### GET /api/sprite-lab/assets/:id/versions/:version_id

Read a single version row.

**Errors:** 400 `PARAMS_REQUIRED`; 404 `VERSION_NOT_FOUND`;
500 `VERSION_GET_FAILED`.

#### POST /api/sprite-lab/assets/:id/versions/:version_id/restore

Clone bytes from a source version into a **new** version (no
overwrite). New `version_number = max(existing) + 1`. Tag set to
`"restored from v<N>"`; `metadata_json.restored_from_version_id` +
`restored_from_version_number` recorded.

**Response (200):** `{ "ok": true, "version": { ..., "version_number":
8, "metadata_json": { "tag": "restored from v3", ... }, ... } }`.

The retention trigger may evict the oldest unpinned version on insert
when the count would exceed 10.

**Errors:** 404 `VERSION_NOT_FOUND` (source);
500 `VERSION_NUM_FAILED` / `VERSION_RESTORE_FAILED`.

#### POST /api/sprite-lab/assets/:id/versions/:version_id/pin

Toggle the `pinned` flag. Pinned versions are exempt from retention
eviction; you can always recover them by unpinning + restoring.

**Response (200):** `{ "ok": true, "version": { ..., "pinned": true } }`.

#### DELETE /api/sprite-lab/assets/:id/versions/:version_id

Delete a version. **Refuses** to delete pinned rows: returns 403
`VERSION_PINNED` with `message: "unpin before deleting"`. Unpin first
(POST `/pin` toggles), then DELETE.

**Response (200):** `{ "ok": true, "deleted": { "id": "...",
"version_number": 5 } }`.

**Errors:** 403 `VERSION_PINNED`; 404 `VERSION_NOT_FOUND`;
500 `VERSION_DELETE_FAILED`.

### Providers extended (Track H — C7-NEW-08 / C7-NEW-09)

#### POST /api/sprite-lab/providers/imagen4/generate

Calls Google's Imagen 4 family via the Gemini API (BYOK
`X-Gemini-Key` or env `GEMINI_API_KEY`). Three quality tiers map to
distinct upstream models and prices.

**Auth:** Bearer JWT + Gemini key (header or env).

**Request body:**
```json
{
  "prompt":     "pixel art knight, walk strip 4 frames, magenta bg",
  "quality":    "fast" | "standard" | "ultra",   // default "fast"
  "size":       "1024x1024" | null,              // optional, hint only
  "style":      "16-bit JRPG"                    // optional positive fragment
}
```

**Quality → model snapshot:**

| Quality   | Model                                     | Price/image |
|-----------|-------------------------------------------|-------------|
| `fast`    | `imagen-4.0-fast-generate-001`            | $0.02       |
| `standard`| `imagen-4.0-generate-001`                 | $0.04       |
| `ultra`   | `imagen-4.0-ultra-generate-preview-06-06` | $0.08       |

> ⚠️ **Status DEC-SF-030:** Wave 3 smoke flagged `fast` as **not
> production-ready** for Spriteoven sprite-sheet prompts (ignores
> chroma + frame-strip structure). `standard` PASSES quality but
> exceeds the cost threshold for Tier 1; reclassified as **Tier 2
> candidate Ciclo 8+**. `ultra` not enabled in production. Keep this
> endpoint for opt-in BYOK experiments only.

**Response (200):**
```json
{
  "ok": true,
  "image": "data:image/png;base64,...",
  "meta": {
    "provider": "imagen4",
    "model_snapshot": "imagen-4.0-fast-generate-001",
    "quality": "fast",
    "aspect_ratio": "1:1",
    "cost_usd": 0.02,
    "elapsedMs": 1234
  }
}
```

**Errors:** 400 `IMAGEN4_QUALITY_INVALID` / `IMAGEN4_PROMPT_REQUIRED`;
503 `byok_required` / `missing_api_key`; 429 `RATE_LIMITED`; 500
`IMAGEN4_GENERATE_FAILED`.

#### POST /api/sprite-lab/providers/grok/test-key

Lightweight Grok key validation — generates a tiny ping image
(`"ping: tiny test image, magenta square"`) and returns model snapshot
+ sample cost. Used by the BYOK form to confirm keys before saving them
to sessionStorage. Costs ≈$0.02 per call.

**Auth:** Bearer JWT + xAI key (`X-XAI-Key` header or env).

**Request body:** none required.

**Response (200):**
`{ "ok": true, "valid": true, "model": "grok-imagine-image-quality",
"sample_cost": 0.02 }`.

**Errors:** 503 `byok_required` (no key); 4xx `grok_test_failed` (Grok
upstream error; status mirrored).

---

## Auth & admin

### GET /api/auth/me

**Auth:** Bearer JWT. Returns
`{ "id": "...", "email": "...", "created_at": "..." }`.

### POST /api/byok/validate

Pings OpenAI with the user's key; never persists.

**Auth:** none. **Request:** `{ "key": "sk-..." }`.

**Response:** `{ "valid": true, "masked": "sk-...XYZ" }` or
`{ "valid": false, "error": "..." }`. **Errors:** 400 `missing_key`.

### POST /api/email/test

Admin-only Resend smoke endpoint. Sends a transactional template render
via Resend.

**Auth:** Bearer JWT + `req.user.email === ADMIN_EMAIL` (default
`fergonllan@gmail.com`, override via `ADMIN_EMAIL` env).

**Request:** `{ "template": "welcome" | "magic-link" | "batch-completed", "to_override": "..." }`.

**Response:** `{ "ok": true, "template": "...", "to": "...", "email_id": "...", "response": {...} }`.

**Errors:** 400 `missing_template` / `unknown_template`; 403
`admin_only`; 500 `resend_failed`.

### GET /api/sprite-lab/status

**Auth:** none. Returns build hash + provider availability flags. Use
this for health checks and "which model can I use right now" decisions.

---

## Error reference

Custom Spriteoven error codes (across all endpoints). Standard
HTTP-status-only errors (e.g. plain 404 for unknown routes) are not
listed.

| Code | Status | Endpoint(s) | Meaning |
| ---- | ------ | ----------- | ------- |
| `EMPTY_PROMPT` | 400 | nb2, gpt-image-2, grok | `prompt` missing/blank. |
| `NO_API_KEY` | 400 | nb2, tileset, iconpack | Provider key absent. |
| `EMPTY_IMAGE` | 400 | postprocess | `imageBase64` missing. |
| `BAD_BASE64` | 400 | postprocess | Cannot decode base64. |
| `EMPTY_FRAMES` | 400 | pack | `frames[]` empty. |
| `BAD_FRAME_BASE64` | 400 | pack | Frame not decodable. |
| `INVALID_ENGINE` | 400 | pack | `engine` not auto/custom/aseprite. |
| `INVALID_EXTRUDE` | 400 | pack | `packOptions.extrude` ∉ [0,8]. |
| `INVALID_OUTPUT` / `EMPTY_OUTPUTS_ARRAY` | 400 | pack | Bad `outputs[]`. |
| `MULTI_OUTPUT_FAILED` | 500 | pack | Bundle assembly failure. |
| `PACK_FAILED` | 500 | pack | Generic pack error. |
| `PALETTE_*` | 400 | postprocess, pack | Palette validation failure. |
| `STITCH_*` | 400 | stitch | Stitch validation failure. |
| `STITCH_FAILED` | 500 | stitch | Stitch internal error. |
| `INVALID_TILESET_PARAMS` | 400 | tileset/generate | Validation. |
| `INVALID_ICONPACK_PARAMS` | 400 | iconpack/generate | Validation. |
| `GPT_IMAGE_2_API_NOT_GA` | 503 | gpt-image-2, tileset (model=gpt-image-2) | Upstream API not GA. |
| `RATE_LIMITED` | 429 | nb2, gpt-image-2, tileset, iconpack | Provider rate-limit. |
| `AI_PROVIDER_ERROR` | 500 | tileset, iconpack | Generic upstream error. |
| `TILESET_GENERATE_FAILED` | 500 | tileset/generate | Internal. |
| `ICONPACK_GENERATE_FAILED` | 500 | iconpack/generate | Internal. |
| `TILEMAP_INVALID` / `TILEMAP_FORMAT_INVALID` | 400 | tilemap/export | Validation. |
| `TILEMAP_FORBIDDEN` | 403 | tilemap/save | Cross-user project. |
| `TILEMAP_SAVE_FAILED` / `TILEMAP_EXPORT_FAILED` | 500 | tilemap/* | Internal. |
| `BATCH_BAD_REQUEST` / `BATCH_EMPTY_JOBS` / `BATCH_TOO_MANY_JOBS` / `BATCH_BAD_JOB` / `BATCH_BAD_JOB_TYPE` / `BATCH_BAD_CONCURRENCY` | 400 | batch | Validation. |
| `BATCH_NOT_FOUND` | 404 | batch/stream | Unknown id. |
| `BATCH_FORBIDDEN` | 403 | batch/stream | Caller is not owner. |
| `PROJECT_NAME_REQUIRED` / `PROJECT_NAME_TOO_LONG` / `PROJECT_NAME_INVALID` / `PROJECT_CONFIG_INVALID` / `PROJECT_PATCH_EMPTY` / `PROJECT_ID_REQUIRED` | 400 | projects/* | Validation. |
| `PROJECT_NOT_FOUND` | 404 | projects/:id | Not found OR cross-user (RLS hides existence). |
| `PROJECT_INSERT_FAILED` / `PROJECT_LIST_FAILED` / `PROJECT_GET_FAILED` / `PROJECT_UPDATE_FAILED` / `PROJECT_DELETE_FAILED` / `PROJECT_LOOKUP_FAILED` | 500 | projects/* | DB error. |
| `SUPABASE_NOT_CONFIGURED` | 503 | projects, tilemap | Server env missing Supabase creds. |
| `unauthorized` | 401 | every Bearer JWT route | Missing / invalid / unverifiable token. |
| `admin_only` | 403 | email/test | Caller is not the configured admin. |
| `byok_required` | 503 | grok/generate | XAI key absent. |
| `missing_api_key` | 503 | providers/gpt-image-2 | OpenAI key absent. |
| `missing_key` | 400 | byok/validate | Body `key` missing. |
| `bad_request` | 400 | validate-similarity | Body validation failure. `reason` field carries detail. |
| `deprecated` | 410 | pack (engine=aseprite) | Engine removed in Wave 5 / DEC-SF-018. |
| `INTERNAL` | 500 | nb2, gpt-image-2, grok | Generic catch-all. |
| `ASSET_NOT_FOUND` | 404 | assets/* (single, list filters) | Missing OR RLS-hidden OR soft-deleted. |
| `ASSET_FORBIDDEN` | 403 | assets/:id/versions/* | Cross-user access to versions endpoints (RLS-404 escalated). |
| `ASSET_ID_REQUIRED` / `ASSET_NAME_INVALID` / `ASSET_NAME_TOO_LONG` / `ASSET_TAGS_INVALID` / `ASSET_PATCH_EMPTY` | 400 | assets PATCH | Validation. |
| `ASSET_LIST_FAILED` / `ASSET_SEARCH_FAILED` / `ASSET_GET_FAILED` / `ASSET_UPDATE_FAILED` / `ASSET_DELETE_FAILED` / `ASSET_LOOKUP_FAILED` / `ASSET_OP_FAILED` | 500 | assets/* | DB error. |
| `QUERY_REQUIRED` | 400 | assets/search | `?q=` missing. |
| `BULK_EMPTY` / `BULK_TOO_MANY` / `BULK_BAD_ID` | 400 | assets/bulk-* | Validation (`asset_ids` shape; max 200). |
| `BULK_TAG_NOOP` | 400 | assets/bulk-tag | Both `tags_add` and `tags_remove` empty. |
| `BULK_FORBIDDEN` | 403 | assets/bulk-* | All-or-nothing: any cross-user/missing id aborts (`missing_count` carried). |
| `BULK_LOOKUP_FAILED` / `BULK_DELETE_FAILED` / `BULK_TAG_LOOKUP_FAILED` / `BULK_TAG_UPDATE_FAILED` / `BULK_TAG_FAILED` / `BULK_EXPORT_LOOKUP_FAILED` / `BULK_EXPORT_FAILED` | 500 | assets/bulk-* | Internal. |
| `PARAMS_REQUIRED` | 400 | versions/* | `:id` or `:version_id` empty. |
| `VERSION_NOT_FOUND` | 404 | versions/* | Within owned asset, version row missing. |
| `VERSION_PINNED` | 403 | versions DELETE | Refuses to delete pinned rows; unpin first. |
| `VERSION_GET_FAILED` / `VERSION_NUM_FAILED` / `VERSION_RESTORE_FAILED` / `VERSION_PIN_FAILED` / `VERSION_DELETE_FAILED` / `VERSIONS_LIST_FAILED` | 500 | versions/* | Internal. |
| `IMAGEN4_QUALITY_INVALID` / `IMAGEN4_PROMPT_REQUIRED` | 400 | providers/imagen4/generate | Validation. |
| `IMAGEN4_GENERATE_FAILED` | 500 | providers/imagen4/generate | Upstream / internal. |
| `grok_test_failed` | 4xx | providers/grok/test-key | Mirrors Grok upstream status. |

---

## Rate limiting

Spriteoven does not yet apply server-side rate limits — those land with
**C7-NEW-05** in Wave 5. In the meantime, the **upstream provider
limits** are the binding ones:

- **OpenAI Tier 1** caps at **5 images per minute** for `gpt-image-2`.
  Burst usage above this returns `429 RATE_LIMITED` with
  `suggestedBackoffMs: 30000`. Tier upgrades remove the cap; see
  [docs/PROVIDERS.md](../PROVIDERS.md).
- **xAI Grok**: per-key concurrency limit; 429 with `retry_after` when
  exceeded.
- **Google Gemini (NB2)**: free tier ~15 RPM; 429 with
  `suggestedBackoffMs: 30000`.
- **Resend (email)**: 10 req/s on the free tier; admin-only endpoint, so
  not consumer-visible.

When you hit a 429, the response body always includes a backoff hint
(`suggestedBackoffMs` or `retry_after`). Honor it: retrying immediately
will cascade further rate-limits.

---

## Changelog

| Doc version | Date | Notes |
| ----------- | ---- | ----- |
| `0.1.0` | 2026-05-08 | Initial publication. Wave 3 endpoints documented as TODO placeholders pending Tracks G/H/I closure. Covers Wave 1 + Wave 2 + Wave 3 housekeeping endpoints in `main`. |
| `0.2.0` | 2026-05-09 | Wave 3 NEW: Library + Versions + Providers extended + Imagen 4 (Fast DROPPED Ciclo 7 per DEC-SF-030, Standard reclassified Tier 2 Ciclo 8+). 15 new endpoints documented in §10 with full schemas + curl examples. AI Models Roadmap echo (§9). New error codes: `ASSET_*`, `BULK_*`, `VERSION_*`, `IMAGEN4_*`. `/docs/api-reference` middleware order verified empirically (route registered pre-catch-all line ~152, GET returns Content-Type `text/markdown`; reported bug not reproducible in HEAD `ea3a9a7`). |
