# Fast AFF API — Reference

API tạo ảnh và video qua Google Labs / Veo với multi-account, captcha-server
tích hợp, proxy xoay (ShopLike).

> **Base URL:** host nơi API chạy — trên VPS là sau **nginx `:80`** (IP/domain của bạn); local `dotnet run` theo cổng Kestrel trong `launchSettings`. Ví dụ dưới dùng `http://localhost:5001`.
> ⚠️ **KHÔNG phải `:5000`** — `:5000` là **captcha server** (dịch vụ khác chạy cùng máy), không phải API.
>
> **📚 Schema SỐNG (đầy đủ + luôn mới nhất, kể cả endpoint nội bộ/admin):** Swagger UI ở **[`/swagger`](/swagger)** — auto-generate từ code, có mô tả từng endpoint + nút **Authorize** (`X-API-Key`) để gọi thử ngay. Tài liệu `.md` này curate phần **gen-API chính** (Images/Videos/R2V/F2V) cho người tích hợp; mọi endpoint khác (Settings, RBAC, Wallets, Monitor, Reports…) — xem `/swagger` để chắc chắn không lỗi thời.

---

## Mục lục

1. [Quick Start](#quick-start)
2. [Authentication](#authentication)
3. [Health](#health)
4. [Me](#me)
5. [Profiles](#profiles)
6. [Tags](#tags)
7. [Taxonomy (Categories)](#taxonomy-categories)
8. [Fingerprints](#fingerprints)
9. [Images](#images)
10. [Videos](#videos)
11. [R2V — Reference-to-Video](#r2v--reference-to-video)
12. [F2V — Frame-to-Video](#f2v--frame-to-video)
13. [Gallery](#gallery)
14. [Library](#library)
15. [Settings](#settings)
16. [In-Page Submit (Option A)](#in-page-submit-option-a)
17. [Bearer Pool Sync](#bearer-pool-sync)
18. [API Keys](#api-keys)
19. [Roles & Permissions (RBAC)](#roles--permissions-rbac)
20. [Audit Log](#audit-log)
21. [GoClaw AI Assistant](#goclaw-ai-assistant)
22. [Wallets](#wallets)
23. [Captcha Pool](#captcha-pool)
24. [Diagnostics](#diagnostics)
25. [Reports](#reports)
26. [Models](#models)
27. [Errors](#errors)
28. [Operational](#operational)

---

## Quick Start

### 1. Set API key
Tải tab **Settings** trong UI → paste API Key → Save.

Hoặc từ CLI:
```bash
export FASTAFF_KEY="test-key-12345"
```

### 2. Tạo profile (cần Bearer + projectId từ Google Labs)

```bash
curl -X POST http://localhost:5001/api/v1/profiles \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acc-01",
    "projectId": "PASTE_PROJECT_ID",
    "bearerToken": "ya29.PASTE_BEARER_HERE"
  }'
```

### 3. Generate ảnh (đồng bộ)

```bash
curl -X POST http://localhost:5001/api/v1/images \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "a red apple on wooden table", "count": 1 }'
```

Response: array `ImageJobResponse[]` — mỗi item có `imageUrl` để tải ảnh.

### 4. Generate video (bất đồng bộ)

```bash
# Submit
curl -X POST http://localhost:5001/api/v1/videos \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "a cat chasing a butterfly in slow motion" }'

# Poll trạng thái (trả về jobId = "xyz789" từ bước trên)
curl http://localhost:5001/api/v1/videos/xyz789 -H "X-API-Key: $FASTAFF_KEY"
```

### 5. Generate video từ ảnh tham chiếu — R2V (bất đồng bộ + multipart)

```bash
# Submit kèm 1+ ảnh tham chiếu — content-type là multipart/form-data
curl -X POST http://localhost:5001/api/v1/r2v \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=khỉ ăn quả hồng, cinematic" \
  -F "aspectRatio=VIDEO_ASPECT_RATIO_LANDSCAPE" \
  -F "images=@ref1.jpg" \
  -F "images=@ref2.jpg"

# Poll giống Videos: GET /api/v1/r2v/{jobId}
curl http://localhost:5001/api/v1/r2v/xyz789 -H "X-API-Key: $FASTAFF_KEY"
```

> ⚠️ Đây chỉ là **ví dụ nhanh**. Tài liệu đầy đủ — bảng field (`profileId`,
> `videoModelKey`, `aspectRatio`, `seed`, `webhookUrl`), bảng model key, tất
> cả endpoint video — xem mục **[Videos](#videos)**. R2V (kèm ảnh tham
> chiếu) có flow + endpoint riêng — xem **[R2V](#r2v--reference-to-video)**.

---

## Authentication

Mọi `/api/*` endpoint đòi header:

```
X-API-Key: <your-api-key>
```

Sai/thiếu → `401 Unauthorized`. Public endpoints không cần key: `GET /`
(UI), `GET /swagger`, `GET /health`, `GET /favicon.ico`.

Admin endpoints (API Keys, Bearer Pool Sync) đòi thêm điều kiện
`isAdmin: true` trên key đang dùng → thiếu quyền → `403 Forbidden`.

Concurrency cap: chỉ `POST /api/v1/images` tính vào giới hạn per-key
(`maxConcurrent`). Vượt giới hạn → `429 Too Many Requests` với header
`Retry-After: 5` và body:
```json
{
  "error": "Concurrency limit reached (2). Wait for an in-flight request to finish.",
  "inFlight": 2,
  "maxConcurrent": 2
}
```

---

## Health

### `GET /health`

Public — không cần `X-API-Key`. Dùng cho uptime monitor, orchestrator ping.

**Response 200**:
```json
{
  "ok": true,
  "status": "ok",
  "version": "1.0.0",
  "activeProfiles": 3,
  "utc": "2026-05-21T08:00:00.000Z"
}
```

```bash
curl http://localhost:5001/health
```

---

## Me

### `GET /api/v1/me`

Trả metadata về API key đang dùng. UI dùng để quyết định có hiện mục
"API Keys" (admin) hay không.

**Response 200** — `MeResponse`:
```json
{
  "id": 1,
  "prefix": "test-k",
  "label": "dev-key",
  "isAdmin": true
}
```

```bash
curl http://localhost:5001/api/v1/me -H "X-API-Key: $FASTAFF_KEY"
```

---

## Profiles

Mỗi profile = 1 Google account, có bearer token + tuỳ chọn
proxy/captcha-server riêng. Profile gắn tag `video` thì mới được dùng cho
Veo3 video generation (cần tài khoản Google trả phí).

### `GET /api/v1/profiles`

List toàn bộ profiles (đã redact secrets).

**Response 200** — `ProfileResponse[]`:
```json
[
  {
    "id": "abc123def456",
    "name": "acc-01",
    "email": "x@gmail.com",
    "projectId": "40020ed1-3b52-47ec-9e9a-4c503dd35f0b",
    "sessionId": null,
    "status": "active",
    "failCount": 0,
    "lastUsedAt": "2026-05-21T10:00:00Z",
    "createdAt": "2026-05-20T08:00:00Z",
    "hasBearer": true,
    "hasCookie": false,
    "proxy": "10.20.30.40:8080",
    "hasProxyAuth": false,
    "hasProxyRotationKey": false,
    "proxyMode": "static",
    "captchaServerUrl": null,
    "bearerUpdatedAt": "2026-05-20T08:00:00Z",
    "tags": ["video", "batch-a"],
    "notes": null,
    "captchaMode": "fresh",
    "paygateTier": "PAYGATE_TIER_ONE"
  }
]
```

```bash
curl http://localhost:5001/api/v1/profiles -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/profiles/{id}`

Get 1 profile. Response giống item trong list.

```bash
curl http://localhost:5001/api/v1/profiles/abc123def456 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `GET /api/v1/profiles/{id}/fingerprint`

**IMP-01 (2026-06-01)** — Trả về tuple fingerprint mà captcha server map profileId này sang. UI "Xem fingerprint" trong profile edit dialog gọi endpoint này.

Mapping ổn định: `hash(profileId) % 29` tuple từ pool. Cùng profileId → luôn cùng fingerprint qua mọi restart. Profile khác → fingerprint khác (nhưng có thể collision với pool 29 entries — canvas_seed + audio_seed vẫn unique per profileId).

```bash
curl http://localhost:5001/api/v1/profiles/abc123def456/fingerprint \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Response 200** — `ProfileFingerprintResponse`:

| Field | Type | Mô tả |
|---|---|---|
| `status` | string | `ok` thành công; `unavailable` nếu captcha server pre-IMP-01; `parse_error` nếu response không hợp lệ |
| `profileId` | string | profileId echo lại |
| `fingerprintId` | string | Tên tuple, vd `w11-uhd620-8c-8g-1920` |
| `platform` | string | Luôn `Windows` |
| `platformVer` | string | `10.0.0` (Win10) hoặc `15.0.0` (Win11) |
| `architecture` | string | `x86` |
| `bitness` | string | `64` |
| `hardwareConcurrency` | int | CPU cores (4 / 6 / 8 / 12 / 16) |
| `deviceMemory` | int | RAM GB (4 / 8 / 16) |
| `screenWidth` | int | vd 1920 / 2560 |
| `screenHeight` | int | vd 1080 / 1440 |
| `webglVendor` | string | vd `Google Inc. (NVIDIA)` |
| `webglRenderer` | string | Full ANGLE renderer string |
| `languages` | string[] | vd `["en-US","en"]` hoặc `["vi-VN","vi","en-US","en"]` |
| `userAgent` | string | UA template constant |
| `chromeVersion` | string | `148.0.7778.179` |
| `poolSize` | int | Tổng số tuple trong pool (29) |
| `note` | string | Optional. Set khi status != `ok` |

**Status codes:** `200 OK` | `404 Not Found` (profile không tồn tại hoặc ngoài RBAC scope)

---

### `POST /api/v1/profiles`

Tạo profile mới. Bearer token lấy từ Chrome DevTools khi đang ở
labs.google/fx/vi/tools/flow.

**Request body** — `CreateProfileRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `name` | string | ✓ | Tên hiển thị, 1-64 ký tự |
| `email` | string | | Gmail account, optional |
| `projectId` | string | | Lấy từ body request `flowMedia:batchGenerateImages` |
| `sessionId` | string | | Không dùng — auto-gen mỗi request |
| `bearerToken` | string | ✓ | Authorization Bearer (`ya29....`), 16-8192 ký tự |
| `cookieJson` | string | | Cookie blob, optional, tối đa 65536 ký tự |
| `proxy` | string | | Static proxy `host:port` hoặc `user:pass@host:port`, tối đa 256 ký tự |
| `proxyRotationKey` | string | | ShopLike access_token (proxy động — không dùng đồng thời với `proxy` tĩnh), tối đa 256 ký tự |
| `captchaServerUrl` | string | | Override captcha server URL cho riêng profile này (URL hợp lệ, tối đa 512 ký tự) |
| `tags` | string[] | | Nhãn tự do. Chuẩn hoá server-side: trim, dedupe. Tag mới (chưa có trong `tag_meta`) **tự động đăng ký** vào Tag Manage với màu gray `#9ca3af` — admin đổi màu sau qua [PATCH /api/v1/tags](#tags). Tag `video` cho phép dùng profile với Veo3 |
| `notes` | string | | Ghi chú tự do của operator, tối đa 2048 ký tự |
| `captchaMode` | string | | `fresh` (mặc định — Chrome spawn mỗi request) hoặc `profile` (warm Chrome pool theo profileId). Giá trị khác → `400 invalid_captcha_mode` |
| `paygateTier` | string | | Gói Veo3 của account: `PAYGATE_TIER_ONE` (Pro, **mặc định**) hoặc `PAYGATE_TIER_TIER1P5` (Ultra). Null/empty = Pro. Giá trị khác → `400 invalid_paygate_tier`. Quyết định model video được phép — chọn sai gói → gen báo `403 MODEL_ACCESS_DENIED` |

**Response 201** — `ProfileResponse` (xem GET).

```bash
curl -X POST http://localhost:5001/api/v1/profiles \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acc-01",
    "email": "x@gmail.com",
    "projectId": "40020ed1-3b52-47ec-9e9a-4c503dd35f0b",
    "bearerToken": "ya29.a0AQvPyIN...",
    "proxyRotationKey": "SHOPLIKE_KEY_HERE",
    "tags": ["video"]
  }'
```

---

### `PATCH /api/v1/profiles/{id}`

Update fields một phần. Mọi field đều optional. `null` = leave unchanged.
Empty string = clear (vd `"proxy": ""` → xoá proxy).

Riêng `tags`: gửi mảng để **thay thế toàn bộ** tag; `[]` = xoá hết; bỏ
field (null) = giữ nguyên. Tag mới được tự động đăng ký vào Tag Manage —
xem [Tags](#tags).

`status` cho phép: `active`, `cooldown`, `banned`, `needs_relogin`. Đặt
`active` sẽ reset `failCount` về 0.

`captchaMode` cho phép: `fresh` | `profile`. Khi đổi sang/đang ở
`profile` và `cookieJson` thay đổi cùng request, server tự động đẩy cookie
mới sang captcha pool (xem [Captcha Pool](#captcha-pool)).

`paygateTier` cho phép: `PAYGATE_TIER_ONE` (Veo3 **Pro**, mặc định) |
`PAYGATE_TIER_TIER1P5` (Veo3 **Ultra**). Đây là source-of-truth cho tier
khi gen video: cả path VPS (VeoClient/R2VClient/F2VClient) lẫn path Local
Worker đều đọc field này của profile. Đổi sai gói → gen video trả
`403 MODEL_ACCESS_DENIED`.

```bash
curl -X PATCH http://localhost:5001/api/v1/profiles/abc123def456 \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "bearerToken": "ya29.NEW_TOKEN", "proxy": "", "tags": ["video", "vip"], "captchaMode": "profile" }'
```

**Status codes:** `200 OK` | `400 Bad Request` (proxy format sai / status / captchaMode không hợp lệ) | `404 Not Found`

---

### `DELETE /api/v1/profiles/{id}`

Xoá profile.

```bash
curl -X DELETE http://localhost:5001/api/v1/profiles/abc123def456 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `204 No Content` | `404 Not Found`

---

### `POST /api/v1/profiles/{id}/rotate-proxy`

Force ShopLike `getNewProxy` → cấp IP mới. Chỉ hoạt động với profile có
`proxyRotationKey`.

**Response 200** khi thành công:
```json
{ "ok": true, "proxy": "1.2.3.4:8080" }
```

**Response 200** khi đang cooldown:
```json
{ "ok": false, "error": "Please wait", "nextChangeSeconds": 30 }
```

**Response 400** khi profile không có `proxyRotationKey`:
```json
{ "error": "Profile is not in rotation mode (no proxy_rotation_key set).", "reason": "no_rotation_key" }
```

```bash
curl -X POST http://localhost:5001/api/v1/profiles/abc123def456/rotate-proxy \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `POST /api/v1/profiles/bulk-tags`

Gắn và/hoặc bỏ tag trên nhiều profile trong 1 request. Bỏ tag chạy trước,
thêm tag chạy sau. So khớp không phân biệt hoa/thường.

**Request body** — `BulkTagsRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `ids` | string[] | ✓ | Danh sách profile id cần áp dụng (≥ 1) |
| `add` | string[] | | Tag cần gắn cho mọi profile trong `ids` |
| `remove` | string[] | | Tag cần bỏ khỏi mọi profile trong `ids` |

Cần ít nhất 1 tag ở `add` hoặc `remove`, nếu không → `400`.

**Response 200**:
```json
{ "updated": 3 }
```
`updated` = số profile thực sự có thay đổi (profile không tồn tại bị bỏ qua).

Tag trong `add` chưa có trong `tag_meta` sẽ **tự động được đăng ký** vào Tag Manage
(màu gray mặc định) — xem [Tags](#tags).

```bash
curl -X POST http://localhost:5001/api/v1/profiles/bulk-tags \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["abc123", "def456"], "add": ["video", "batch-b"], "remove": ["batch-a"] }'
```

---

### `POST /api/v1/profiles/bulk-delete`

Xoá nhiều profile trong 1 request. Mỗi id bị xoá cũng kích hoạt dọn pool
captcha tương ứng (fire-and-forget).

**Request body** — `BulkDeleteRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `ids` | string[] | ✓ | Danh sách profile id cần xoá (≥ 1) |

**Response 200**:
```json
{ "deleted": 2 }
```
`deleted` = số profile thực sự bị xoá (id không tồn tại bị bỏ qua).

```bash
curl -X POST http://localhost:5001/api/v1/profiles/bulk-delete \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["abc123", "def456"] }'
```

---

### `POST /api/v1/profiles/{id}/clone`

Nhân bản 1 profile thành N bản. Mọi field được sao y — gồm cả bearer/cookie/
proxy đã mã hoá — nên mỗi clone là bản chạy được ngay. Mỗi clone có id mới,
tên đánh số (`"Name (copy N)"`) và runtime state reset (`status=active`,
`failCount=0`, `lastUsedAt=null`).

**Request body** — `CloneProfileRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `count` | int | | Số bản cần tạo, 1–50. Mặc định 1 |

**Response 200** — `ProfileResponse[]` (danh sách clone vừa tạo).

```bash
curl -X POST http://localhost:5001/api/v1/profiles/abc123def456/clone \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "count": 5 }'
```

**Status codes:** `200 OK` | `400 Bad Request` (count ngoài 1–50) | `404 Not Found`

---

### `GET /api/v1/profiles/{id}/secrets`

**Admin-only.** Trả về secret đã giải mã (bearer, cookie, proxy đầy đủ,
rotation key) để UI admin pre-fill form edit. Đây là endpoint DUY NHẤT lộ
secret thô — list/get thường chỉ trả cờ `hasBearer`/`hasCookie`.

**Response 200** — `ProfileSecretsResponse`:
```json
{
  "bearerToken": "ya29.a0AQ...",
  "cookieJson": "[{\"name\":\"SID\",...}]",
  "proxy": "user:pass@1.2.3.4:8080",
  "proxyRotationKey": "SHOPLIKE_KEY"
}
```
Field nào profile không có → `null`.

```bash
curl http://localhost:5001/api/v1/profiles/abc123def456/secrets \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden` (không phải admin key) | `404 Not Found`

---

### `GET /api/v1/profiles/{id}/quota-usage`

Phase 52 — đọc bộ đếm quota theo từng profile cho cả image và video trong 2 cửa sổ thời gian: trailing hour và trailing day. Count chỉ tính job `status='succeeded'`. Phase 54: ranh giới day theo VN+7 ICT (reset lúc 00:00 giờ Việt Nam), ranh giới hour theo top-of-hour UTC. Video count gộp cả 3 bảng `video_jobs` + `r2v_jobs` + `f2v_jobs`.

**Query params:** không có.

**Response 200**:
```json
{
  "quotaEnabled": true,
  "image": {
    "perHour": {
      "limit": 30,
      "used": 12,
      "remaining": 18,
      "resetAt": "2026-06-02T15:00:00Z"
    },
    "perDay": {
      "limit": 200,
      "used": 87,
      "remaining": 113,
      "resetAt": "2026-06-02T17:00:00Z"
    }
  },
  "video": {
    "perHour": {
      "limit": 10,
      "used": 4,
      "remaining": 6,
      "resetAt": "2026-06-02T15:00:00Z"
    },
    "perDay": {
      "limit": 50,
      "used": 22,
      "remaining": 28,
      "resetAt": "2026-06-02T17:00:00Z"
    }
  }
}
```

Khi profile không bật quota hoặc giới hạn cụ thể là 0/null, field `limit` sẽ là `null` và `remaining` cũng là `null` (unlimited tại cửa sổ đó). `resetAt` luôn là mốc thời gian tuyệt đối UTC ISO-8601: hour reset = đầu giờ UTC kế tiếp, day reset = 00:00 ICT kế tiếp (tức 17:00 UTC ngày hôm trước).

**Status codes:** `200 OK` | `404 Not Found` (profile không tồn tại hoặc nằm ngoài scope của API key).

#### curl
```bash
curl http://localhost:5001/api/v1/profiles/4f1a9b2c3d4e5f60718293a4b5c6d7e8/quota-usage \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/profiles/{id}/fingerprint`

IMP-01 (2026-06-01) — trả về snapshot fingerprint mà captcha server đang map cho `profileId` này: bộ (UA-CH, WebGL, hardware, screen, languages) trong pool 29 tuple Win10/11 đã được curate. Fingerprint là **deterministic** theo seed `profileId`, nên cùng một profile sẽ luôn nhận đúng một tuple. Read-only — không có endpoint để override.

Endpoint gọi vòng qua captcha server (`/fingerprint`). Nếu captcha server là bản pre-IMP-01 (chưa có endpoint này) hoặc lỗi transport, response sẽ có `status="unavailable"` kèm `note` giải thích — vẫn trả `200 OK`, không phải lỗi.

**Query params:** không có.

**Response 200** — `ProfileFingerprintResponse`:
```json
{
  "status": "ok",
  "profileId": "4f1a9b2c3d4e5f60718293a4b5c6d7e8",
  "fingerprintId": "win11-chrome-126-rtx3060-01",
  "platform": "Windows",
  "platformVer": "15.0.0",
  "architecture": "x86",
  "bitness": "64",
  "hardwareConcurrency": 16,
  "deviceMemory": 16,
  "screenWidth": 2560,
  "screenHeight": 1440,
  "webglVendor": "Google Inc. (NVIDIA)",
  "webglRenderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)",
  "languages": ["en-US", "en"],
  "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
  "chromeVersion": "126.0.6478.127",
  "poolSize": 29,
  "note": null
}
```

**Response 200 (captcha server pre-IMP-01)** — khi captcha server cũ chưa hỗ trợ `/fingerprint`:
```json
{
  "status": "unavailable",
  "profileId": "4f1a9b2c3d4e5f60718293a4b5c6d7e8",
  "fingerprintId": "",
  "platform": "",
  "hardwareConcurrency": 0,
  "deviceMemory": 0,
  "screenWidth": 0,
  "screenHeight": 0,
  "languages": [],
  "userAgent": "",
  "chromeVersion": "",
  "poolSize": 0,
  "note": "Captcha server did not return a fingerprint — likely pre-IMP-01 build or transport error."
}
```

**Status codes:** `200 OK` | `404 Not Found` (profile không tồn tại hoặc nằm ngoài scope).

#### curl
```bash
curl http://localhost:5001/api/v1/profiles/4f1a9b2c3d4e5f60718293a4b5c6d7e8/fingerprint \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### PATCH update — bổ sung quota fields (Phase 52)

Endpoint `PATCH /api/v1/profiles/{id}` đã được mở rộng để nhận thêm 5 field quota. Toàn bộ tuân theo quy ước cũ: `null` = leave unchanged. Với 4 field `*QuotaPerHour|Day`, giá trị `0` = clear (unlimited tại cửa sổ đó).

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `quotaEnabled` | bool (0/1) | | Bật/tắt enforce quota cho profile này. `false`/`0` = không kiểm tra dù limit có set. |
| `imageQuotaPerHour` | int (0–9999) | | Số ảnh tối đa / giờ. `0` = unlimited tại cửa sổ giờ. |
| `imageQuotaPerDay` | int (0–99999) | | Số ảnh tối đa / ngày (theo VN+7 ICT, Phase 54). `0` = unlimited. |
| `videoQuotaPerHour` | int (0–9999) | | Số video (T2V + R2V + F2V cộng dồn) tối đa / giờ. `0` = unlimited. |
| `videoQuotaPerDay` | int (0–99999) | | Số video / ngày. `0` = unlimited. |

#### curl
```bash
curl -X PATCH http://localhost:5001/api/v1/profiles/4f1a9b2c3d4e5f60718293a4b5c6d7e8 \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quotaEnabled": 1,
    "imageQuotaPerHour": 30,
    "imageQuotaPerDay": 200,
    "videoQuotaPerHour": 10,
    "videoQuotaPerDay": 50
  }'
```

---

## Tags

Quản lý nhãn (tag) gắn lên profile. Tag lưu **dạng CSV** trên cột `profiles.tags`
là source-of-truth (existing) + 1 bảng sidecar `tag_meta` lưu **màu** + thời gian
audit. Tag chia 2 loại:

- **Managed** — đã có row trong `tag_meta` (có màu, được Tag Manage UI quản lý)
- **Implicit** — chỉ xuất hiện trong CSV của 1+ profile, chưa có metadata
  (render màu gray mặc định)

**Auto-register:** mọi tag mới gửi qua [POST /profiles](#post-apiv1profiles),
[PATCH /profiles/{id}](#patch-apiv1profilesid), hoặc [POST /profiles/bulk-tags](#post-apiv1profilesbulk-tags)
sẽ **tự động tạo row `tag_meta`** với màu gray `#9ca3af`. Admin đổi màu sau qua
[PATCH /tags/{name}](#patch-apiv1tagsname).

**Cascade rename/delete:** đổi tên tag → cập nhật CSV trên mọi profile có tag
đó. Xoá tag → strip khỏi mọi CSV (exact-match split, không substring — `video` không
match `videoQA`).

**Permission:** GET cho mọi authenticated key. POST/PATCH/DELETE chỉ admin.

---

### `GET /api/v1/tags`

List toàn bộ tag (managed + implicit) cùng số profile đang dùng.

**Response 200** — `TagResponse[]`:

```json
[
  {
    "name": "video",
    "color": "#10b981",
    "profileCount": 5,
    "isManaged": true,
    "createdAt": "2026-05-30T18:52:03.314",
    "updatedAt": "2026-05-30T18:52:03.334"
  },
  {
    "name": "demo",
    "color": "#9ca3af",
    "profileCount": 2,
    "isManaged": false,
    "createdAt": null,
    "updatedAt": null
  }
]
```

```bash
curl http://localhost:5001/api/v1/tags \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `POST /api/v1/tags`

Tạo tag managed với màu chỉ định. Admin-only.

**Request body** — `CreateTagRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `name` | string | ✓ | 1-32 ký tự. Cho phép chữ (kể cả Việt), số, `_`, `-`, dấu cách. **KHÔNG dấu phẩy** (sẽ vỡ CSV). |
| `color` | string | ✓ | Hex `#RRGGBB` (7 ký tự). Vd `#10b981` |

**Response 200** — `TagResponse` (xem GET).

**Errors:** `400 invalid_tag` (name/color sai regex) · `409 duplicate_tag` (đã tồn tại, case-insensitive).

```bash
curl -X POST http://localhost:5001/api/v1/tags \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "vip", "color": "#ec4899" }'
```

---

### `PATCH /api/v1/tags/{name}`

Đổi tên và/hoặc đổi màu. Admin-only. Phải gửi ≥ 1 field.

Đổi tên = **cascade rewrite CSV** trên mọi profile (exact-match, case-insensitive).

Nếu `{name}` là tag implicit (chỉ trong CSV, chưa có `tag_meta`) → endpoint
tự **tạo row tag_meta** trước khi áp PATCH (giống path Auto-register).

**Request body** — `UpdateTagRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `newName` | string | | Tên mới (1-32 ký tự, cùng regex như Create) |
| `color` | string | | Hex `#RRGGBB` |

**Response 200** — `TagResponse`.

**Errors:** `400 invalid_tag` · `409 duplicate_tag` (newName đã tồn tại).

```bash
curl -X PATCH http://localhost:5001/api/v1/tags/vip \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "color": "#f59e0b" }'
```

---

### `DELETE /api/v1/tags/{name}`

Xoá row `tag_meta` + **strip tag khỏi mọi profile CSV** (cascade). Admin-only.

**Response 200**:
```json
{ "ok": true, "stripped": 3 }
```
`stripped` = số profile CSV vừa bị gỡ tag.

```bash
curl -X DELETE http://localhost:5001/api/v1/tags/vip \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

---

## Taxonomy (Categories)

> 🏷️ Taxonomy = bảng "danh mục" để **phân loại job** (image/video/r2v/f2v) theo chủ đề. UI Gallery dùng các category này để hiển thị sidebar filter. Khác với **Tags** (chỉ áp dụng cho profile), Taxonomy áp dụng cho **JOB**.
>
> **Permission để WRITE**: `tag.write`. READ mở cho mọi key.

### `GET /api/v1/taxonomy`

List tất cả category, sắp xếp theo `sortOrder` tăng dần.

**Query params**:
- `includeInactive` (bool, default `false`): có trả `active=false` không
- `type` (string, optional, một trong `image|video|r2v|f2v`): trả thêm `jobCount` đếm số job theo từng category cho kind đó. Nếu omit → `jobCount = 0` tất cả.

**Response 200**:
```json
[
  {
    "id": 1,
    "name": "ai-art",
    "emoji": "🎨",
    "color": "purple",
    "sortOrder": 10,
    "active": true,
    "jobCount": 142
  }
]
```

```bash
curl http://localhost:5001/api/v1/taxonomy?type=image \
  -H "X-API-Key: $FASTAFF_KEY"
```

### `POST /api/v1/taxonomy`

Tạo category mới. Cần permission `tag.write`.

**Request body**:
```json
{
  "name": "thumbnails",
  "emoji": "🖼️",
  "color": "blue",
  "sortOrder": 20
}
```

| Field | Type | Required | Note |
|---|---|---|---|
| `name` | string | ✅ | 1-64 chars, unique |
| `emoji` | string | – | Max 8 chars, default `""` |
| `color` | string | – | Max 32 chars, default `"gray"` |
| `sortOrder` | int | – | Auto-next nếu omit |

**Response 200**: `CategoryResponse` (như trên).

**Errors**:
- `400 invalid_name` — name rỗng / quá dài
- `403 missing_permission` — không có `tag.write`
- `409 duplicate_name` — name đã tồn tại

### `PATCH /api/v1/taxonomy/{id}`

Partial update category. Cần `tag.write`.

**Path params**: `id` (int, required).

**Request body** (mọi field optional, null = giữ nguyên):
```json
{
  "name": "thumbnails-v2",
  "emoji": "📸",
  "color": "green",
  "sortOrder": 25,
  "active": false
}
```

**Response 200**: `CategoryResponse`. **Errors**: 404 not found, 403, 409 duplicate.

### `DELETE /api/v1/taxonomy/{id}`

Xoá category. Cần `tag.write`.

**Response 200**: `{ "ok": true }`. **Errors**: 404, 403.

### `POST /api/v1/taxonomy/reorder`

Bulk reorder categories — gán lại `sortOrder` theo thứ tự `orderedIds`. Cần `tag.write`.

**Request body**:
```json
{ "orderedIds": [3, 1, 2, 5, 4] }
```

**Response 200**: `{ "ok": true, "count": 5 }`. **Errors**: 400 empty, 403.

---

## Fingerprints

> 🎭 Browser fingerprint pool — captcha server đọc danh sách này để spoof Windows browser khi solve reCAPTCHA. Mỗi fp là 1 bộ (UA, WebGL renderer, screen size, hardware concurrency, device memory, …).
>
> **Builtin fingerprints** (29 tuples) preloaded — `isBuiltin: true`, không xoá được. **Custom** thêm qua POST.
> **Auto-assign pool** = subset (`inAutoPool: true`) — backend phân phối khi profile chưa có override.
> **Authoritative on port 5001** (backend, NOT captcha :5000). Captcha server fetch qua `/resolve` + cache 5 phút.

### `GET /api/v1/fingerprints`

List tất cả fingerprints (builtin + custom), sort theo `sortOrder` asc.

**Response 200**: array of `FingerprintResponse`:
```json
[
  {
    "id": "w11-rtx3060-12c-16g-1920",
    "name": "Win11 RTX 3060 12c/16GB 1920x1080",
    "winVersion": "win11",
    "platformVersion": "15.0.0",
    "arch": "x86", "bitness": "64",
    "hardwareConcurrency": 12,
    "deviceMemory": 16,
    "screenWidth": 1920, "screenHeight": 1080,
    "webglVendor": "Google Inc. (NVIDIA)",
    "webglRenderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)",
    "languages": ["en-US", "en"],
    "isBuiltin": true,
    "inAutoPool": true,
    "sortOrder": 10,
    "createdAt": "2026-06-04T08:00:00Z",
    "updatedAt": "2026-06-04T08:00:00Z",
    "overrideCount": 3,
    "chromeFullVersion": "148.0.7778.179"
  }
]
```

### `GET /api/v1/fingerprints/{id}`

Trả 1 fingerprint cụ thể. **Response 200**: `FingerprintResponse`. **Errors**: 404 `not_found`.

### `GET /api/v1/fingerprints/resolve?profileId={pid}`

**(internal — captcha server gọi, AllowAnonymous)** — resolve fp cho 1 profile: nếu profile có override → trả override; else auto-pick từ pool theo hash profile_id.

**Query params**: `profileId` (string, required).

**Response 200**:
```json
{
  "profileId": "abc123…",
  "fingerprintId": "w11-rtx3060-12c-16g-1920",
  "source": "override",
  "poolSize": 24,
  "fingerprint": { "id": "...", "winVersion": "...", ... },
  "canvasSeed": 8472619382,
  "audioSeed": 1937284756
}
```

**Errors**: 400 `missing_profile_id`, 503 `fingerprint_pool_empty`.

### `POST /api/v1/fingerprints`

Tạo custom fingerprint mới.

**Request body**:
```json
{
  "id": "w11-rx7900-24c-32g-2560",
  "name": "Win11 RX 7900 24c/32GB 2560x1440",
  "winVersion": "win11",
  "platformVersion": "15.0.0",
  "arch": "x86",
  "bitness": "64",
  "hardwareConcurrency": 24,
  "deviceMemory": 32,
  "screenWidth": 2560,
  "screenHeight": 1440,
  "webglVendor": "Google Inc. (AMD)",
  "webglRenderer": "ANGLE (AMD, AMD Radeon RX 7900 XTX Direct3D11 vs_5_0 ps_5_0)",
  "languages": ["en-US", "en"],
  "inAutoPool": true,
  "chromeFullVersion": "148.0.7778.179"
}
```

| Field | Validate | Note |
|---|---|---|
| `id` | required, max 64 | Unique key |
| `winVersion` | required, max 8 | "win10", "win11" |
| `platformVersion` | required, max 16 | UA-CH platform-version |
| `hardwareConcurrency` | 1..128 | navigator.hardwareConcurrency |
| `deviceMemory` | 1..256 | navigator.deviceMemory |
| `screenWidth` | 640..7680 | screen.width |
| `screenHeight` | 480..4320 | screen.height |
| `webglVendor` | required, max 64 | UNMASKED_VENDOR_WEBGL |
| `webglRenderer` | required, max 256 | UNMASKED_RENDERER_WEBGL |
| `languages` | required | navigator.languages |
| `chromeFullVersion` | max 32 | "148.0.7778.179" — phải match Chrome binary trên captcha server |

**Response 201** (Location: `/api/v1/fingerprints/{id}`): `FingerprintResponse`.

**Errors**: 409 `duplicate_or_invalid` (id trùng hoặc validation fail).

### `PUT /api/v1/fingerprints/{id}`

Update fp existing. Tất cả field optional — chỉ field provide mới update.

**Request body** (subset của `CreateFingerprintRequest`):
```json
{
  "inAutoPool": false,
  "screenWidth": 1920,
  "screenHeight": 1200
}
```

**Response 200**: `FingerprintResponse`. **Errors**: 404 `not_found`, 400 `invalid_update`.

### `DELETE /api/v1/fingerprints/{id}`

Xoá custom fp. **Builtin fp KHÔNG xoá được** (blocked 400). **Fp đang được profile override** cũng blocked.

**Response 204** No Content. **Errors**: 404, 400 `delete_blocked`.

### `GET /api/v1/fingerprints/stats?days={n}`

Per-fp success-rate aggregated qua **4 job tables** (image/video/r2v/f2v).

**Query params**: `days` (int, default 7, clamped 1-30).

**Response 200**:
```json
[
  {
    "fingerprintId": "w11-rtx3060-12c-16g-1920",
    "name": "Win11 RTX 3060 12c/16GB",
    "inAutoPool": true,
    "isBuiltin": true,
    "overrideCount": 3,
    "ok": 142,
    "fail": 18,
    "total": 160,
    "successPct": 88.75
  },
  {
    "fingerprintId": "__unsnapshotted",
    "name": "(jobs pre-2026-06-05)",
    "ok": 234, "fail": 41, "total": 275, "successPct": 85.09
  }
]
```

`fingerprintId="__unsnapshotted"` = các job cũ chưa được snapshot fp_id (trước Phase 66 backfill).

### `POST /api/v1/fingerprints/{id}/purge-jobs`

Null out `fingerprint_id` trên job rows tham chiếu fp đó. Dùng khi muốn "tách lịch sử" fp khỏi job để delete fp clean.

**Response 200**:
```json
{ "image": 12, "video": 5, "r2v": 2, "f2v": 1, "total": 20 }
```

### `PATCH /api/v1/fingerprints/{id}/pool`

Toggle `in_auto_pool` flag (soft-disable cho auto-assign).

**Request body**: `{ "inAutoPool": false }`. **Response 200**: `FingerprintResponse`.

**Errors**: 404, 400 `pool_toggle_blocked` (vd nếu disable hết pool thì block — pool phải có ≥ 1).

### `POST /api/v1/fingerprints/backfill`

**One-shot migration** — scan jobs cũ (pre-2026-06-05 không có `fingerprint_id` snapshot), resolve fp từ profile.fingerprint_id và lưu vào job row. Dùng 1 lần sau khi deploy Phase 66.

**Response 200**:
```json
{
  "imageUpdated": 142,
  "videoUpdated": 38,
  "r2vUpdated": 12,
  "f2vUpdated": 5,
  "profilesScanned": 23,
  "profilesUnresolved": 1,
  "elapsedMs": 1843
}
```

---

## Images

> ℹ **Phase 46 (2026-06-01) — Mode snapshot at INSERT:** Mọi job khi tạo sẽ snapshot `captcha_mode` + `proxy_mode` từ profile lúc đó vào job row. Field `captchaMode` và `proxyMode` trong response phản ánh gen-time truth, không phải live profile state. Đổi cấu hình profile sau khi job tạo sẽ KHÔNG ảnh hưởng badge này trong UI.


### `POST /api/v1/images`

Generate 1-4 ảnh. Mỗi ảnh = 1 captcha solve + 1 Google Labs call.

Có **2 chế độ** tuỳ theo `webhookUrl`:

**Chế độ đồng bộ** (không có `webhookUrl`) — mặc định, tương thích với mọi
caller cũ. Server giữ kết nối cho đến khi tất cả ảnh hoàn thành, rồi trả
về toàn bộ kết quả trong 1 response. Mặc định tuần tự (~30s × N); đặt
`parallel: true` để chạy đồng thời.

**Chế độ bất đồng bộ** (có `webhookUrl`) — server trả `202 Accepted` ngay
với danh sách jobId, background worker xử lý và POST kết quả tới
`webhookUrl` khi xong. Xác thực callback bằng header
`X-FastAff-Signature` (HMAC SHA-256 của body, key = `webhookSecret` được
trả về trong 202).

**Request body** — `GenerateImageRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `prompt` | string | ✓ | Prompt text, 1-32768 ký tự |
| `aspectRatio` | string | | `IMAGE_ASPECT_RATIO_LANDSCAPE`, `IMAGE_ASPECT_RATIO_SQUARE`, `IMAGE_ASPECT_RATIO_PORTRAIT` |
| `model` | string | | `GEM_PIX_2` (default), `NARWHAL`, `HARBOR_SEAL` |
| `seed` | int | | Để trống = auto random |
| `profileId` | string | | Để trống = round-robin profile active ít dùng nhất |
| `count` | int | | 1-4, default 1 |
| `parallel` | bool | | `true` + `count > 1` → chạy đồng thời. Default `false` = tuần tự |
| `webhookUrl` | string | | URL callback (http/https). Khi có → async mode, trả 202 |
| `clientJobId` | string | | Hex 32 ký tự tự chọn — dùng để poll logs real-time ngay sau khi submit (chỉ áp dụng cho job đầu trong batch) |

**Response 200** (sync mode) — `ImageJobResponse[]`:
```json
[
  {
    "jobId": "abc123def456abc123def456abc123de",
    "profileId": "p1q2r3s4t5u6",
    "status": "succeeded",
    "prompt": "a red apple on wooden table",
    "aspectRatio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
    "model": "GEM_PIX_2",
    "seed": 707707,
    "imageUrl": "http://localhost:5001/files/20260521/abc123def456abc123def456abc123de.png",
    "error": null,
    "createdAt": "2026-05-21T10:30:00Z",
    "completedAt": "2026-05-21T10:30:32Z"
  }
]
```

**Response 502** khi tất cả jobs fail — vẫn trả `ImageJobResponse[]` với
`status: "failed"`.

**Response 202** (async mode) — `EnqueuedJobResponse[]`:
```json
[
  {
    "jobId": "abc123def456abc123def456abc123de",
    "status": "queued",
    "webhookSecret": "abcdef0123456789abcdef0123456789"
  }
]
```

**Response 400** — validation fail hoặc URL callback không hợp lệ:
```json
{ "error": "Webhook URL must use http or https.", "reason": "invalid_webhook_url" }
```

#### curl (sync)
```bash
curl -X POST http://localhost:5001/api/v1/images \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "two goats fighting on a wooden bridge",
    "aspectRatio": "IMAGE_ASPECT_RATIO_PORTRAIT",
    "model": "GEM_PIX_2",
    "count": 4
  }'
```

#### curl (async webhook)
```bash
curl -X POST http://localhost:5001/api/v1/images \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "a red apple on wooden table",
    "webhookUrl": "https://my-server.com/hooks/image-done"
  }'
```

#### JavaScript
```javascript
const r = await fetch('http://localhost:5001/api/v1/images', {
  method: 'POST',
  headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    prompt: 'two goats fighting on a wooden bridge',
    aspectRatio: 'IMAGE_ASPECT_RATIO_PORTRAIT',
    count: 4,
  }),
});
const jobs = await r.json();
for (const j of jobs) console.log(j.imageUrl);
```

#### Python
```python
r = requests.post(
    "http://localhost:5001/api/v1/images",
    headers={"X-API-Key": API_KEY},
    json={
        "prompt": "two goats fighting on a wooden bridge",
        "aspectRatio": "IMAGE_ASPECT_RATIO_PORTRAIT",
        "count": 4,
    },
    timeout=300,  # 4 ảnh ~2 phút
)
for j in r.json():
    print(j["imageUrl"])
```

#### C#
```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

var resp = await http.PostAsJsonAsync("http://localhost:5001/api/v1/images", new
{
    prompt = "two goats fighting on a wooden bridge",
    aspectRatio = "IMAGE_ASPECT_RATIO_PORTRAIT",
    count = 4,
});
resp.EnsureSuccessStatusCode();
var jobs = await resp.Content.ReadFromJsonAsync<JsonElement>();
foreach (var j in jobs.EnumerateArray())
    Console.WriteLine(j.GetProperty("imageUrl").GetString());
```

---

### `GET /api/v1/images/{jobId}`

Get 1 job theo id. Trả về single object, không phải array.

```bash
curl http://localhost:5001/api/v1/images/abc123def456 -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `GET /api/v1/images`

Recent jobs. Mặc định 50, tối đa 500, sort `createdAt DESC`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `limit` | int | 50 | Số kết quả, 1-500 |
| `offset` | int | 0 | Bỏ qua N kết quả đầu (phân trang) |
| `profileId` | string | | Lọc theo profile |
| `status` | string | | Lọc theo status: `queued`, `running`, `succeeded`, `failed` |
| `since` | datetime | | Chỉ lấy job tạo sau thời điểm này (ISO 8601) |

Khi không dùng filter nào (chỉ `limit`) — dùng code path nhanh hơn
(không phân trang, không offset).

```bash
# 20 job gần nhất
curl "http://localhost:5001/api/v1/images?limit=20" -H "X-API-Key: $FASTAFF_KEY"

# Lọc theo profile + status
curl "http://localhost:5001/api/v1/images?profileId=abc123&status=succeeded&limit=100" \
  -H "X-API-Key: $FASTAFF_KEY"

# Phân trang
curl "http://localhost:5001/api/v1/images?limit=50&offset=50" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/images/{jobId}/file`

Tải file PNG (auth-gated). Dùng khi `<img src>` không tự gửi
`X-API-Key`. Static mount `/files/` cũng phục vụ cùng file.

```bash
curl http://localhost:5001/api/v1/images/abc123def456/file \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o image.png
```

**Status codes:** `200 OK` (`image/png`) | `404 Not Found`

---

### `GET /api/v1/images/{jobId}/logs`

Lấy live-log buffer của 1 job (cập nhật trong-memory khi job đang chạy).
UI gọi mỗi ~1s để hiển thị log real-time.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `since` | int | 0 | Chỉ trả về các dòng từ index N trở đi (giúp poll incremental) |

**Response 200**:
```json
[
  {
    "ts": "2026-05-21T10:30:01.123Z",
    "level": "Information",
    "message": "Captcha solved in 18s"
  },
  {
    "ts": "2026-05-21T10:30:05.456Z",
    "level": "Information",
    "message": "Google Labs call OK — seed=707707"
  }
]
```

```bash
curl "http://localhost:5001/api/v1/images/abc123def456/logs?since=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `POST /api/v1/images/with-refs`

Phase 49 — image-to-image (I2I): sinh ảnh từ prompt + 1 hoặc nhiều ảnh tham chiếu. Ảnh ref có thể upload trực tiếp (`images`) hoặc trỏ tới các asset đã có trong Library (`libraryIds`). Endpoint nhận `multipart/form-data` (giới hạn 64 MB tổng request). Phase 55 đã bỏ giới hạn tối đa 3 ref — số lượng giờ do Google tự enforce. Đối với T2I thuần (không có ref) hãy dùng `POST /api/v1/images` — code path tách riêng (Phase 50) để giữ byte-identical với pre-Phase-49.

**Request** — `multipart/form-data` (không có DTO JSON; controller đọc trực tiếp từ form):

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `prompt` | string | ✓ | Nội dung prompt, đã trim. Tối đa 32768 ký tự. |
| `images` | file (lặp) |  | File ảnh tham chiếu, mime bắt buộc thuộc `image/jpeg`, `image/jpg`, `image/png`, `image/webp`. Có thể đính kèm nhiều file. |
| `libraryIds` | string (CSV) |  | Danh sách ID asset trong Library (kiểu integer), phân tách bằng dấu phẩy, ví dụ `"12,47,108"`. Bypass upload — server resolve sang `mediaId` đã có sẵn. |
| `aspectRatio` | string |  | Ví dụ `IMAGE_ASPECT_RATIO_LANDSCAPE`. Mặc định lấy từ default app config. |
| `model` | string |  | Ví dụ `GEM_PIX_2`, `NARWHAL`, `HARBOR_SEAL`. Nano Banana family dùng cùng model key cho cả T2I và I2I (không cần map R2I). |
| `seed` | int |  | Seed cho deterministic gen. Nếu không parse được integer → bỏ qua. |
| `profileId` | string |  | Profile dùng để chạy gen. Bắt buộc scope-check với API key. |
| `clientJobId` | string |  | ID idempotency phía client, lưu thẳng vào job row. |

Ràng buộc bắt buộc: phải có **ít nhất 1 ref** giữa `images` và `libraryIds` cộng lại — không sẽ 400 `no_refs`. Endpoint **sync only** ở Phase 49: không hỗ trợ `webhookUrl`, `count`, `parallel` (dùng `POST /images` nếu cần các tính năng này).

**Response 200** — `ImageJobResponse` (single object, không phải array):
```json
{
  "jobId": "img_2026060200071f4a9b",
  "status": "Succeeded",
  "prompt": "a cyberpunk corgi riding a neon skateboard, studio lighting",
  "model": "HARBOR_SEAL",
  "aspectRatio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
  "seed": 4242,
  "profileId": "prof_main_01",
  "clientJobId": "client-abc-001",
  "imageUrl": "http://localhost:5001/files/2026/06/02/img_2026060200071f4a9b.png",
  "createdAt": "2026-06-02T00:07:31.482Z",
  "completedAt": "2026-06-02T00:07:48.913Z",
  "billedAmount": 1500
}
```

**Response 400** — validation:
```json
{ "error": "at least one reference image required (upload 'images' OR 'libraryIds')", "reason": "no_refs" }
```
Các `reason` khác có thể gặp: `bad_content_type`, `invalid_prompt`, `invalid_mime`.

**Response 402** — không đủ số dư ví:
```json
{ "error": "Insufficient credit. Needed 1500 VND.", "reason": "insufficient_credit", "pricePer": 1500 }
```

**Response 403** — API key không có quyền dùng profileId:
```json
{ "error": "API key not allowed to use this profile.", "reason": "profile_scope" }
```

**Response 502** — job đã được tạo nhưng Google trả lỗi (job row vẫn lưu, vẫn có thể xem qua `GET /images/{jobId}`):
```json
{ "jobId": "img_2026060200071f4a9b", "status": "Failed", "errorMessage": "Grecaptcha not ready", "prompt": "...", "createdAt": "2026-06-02T00:07:31.482Z" }
```

**Status codes:** `200 OK` | `400 Bad Request` | `402 Payment Required` | `403 Forbidden` | `502 Bad Gateway`

#### curl
```bash
curl -X POST http://localhost:5001/api/v1/images/with-refs \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=a cyberpunk corgi riding a neon skateboard, studio lighting" \
  -F "model=HARBOR_SEAL" \
  -F "aspectRatio=IMAGE_ASPECT_RATIO_LANDSCAPE" \
  -F "seed=4242" \
  -F "profileId=prof_main_01" \
  -F "clientJobId=client-abc-001" \
  -F "libraryIds=12" \
  -F "images=@C:/refs/style-ref.png;type=image/png" \
  -F "images=@C:/refs/pose-ref.jpg;type=image/jpeg"
```

#### JavaScript (Node 20+, native FormData)
```js
import fs from "node:fs";

const form = new FormData();
form.append("prompt", "a cyberpunk corgi riding a neon skateboard, studio lighting");
form.append("model", "HARBOR_SEAL");
form.append("aspectRatio", "IMAGE_ASPECT_RATIO_LANDSCAPE");
form.append("seed", "4242");
form.append("profileId", "prof_main_01");
form.append("libraryIds", "12");
form.append("images", new Blob([fs.readFileSync("./style-ref.png")], { type: "image/png" }), "style-ref.png");
form.append("images", new Blob([fs.readFileSync("./pose-ref.jpg")], { type: "image/jpeg" }), "pose-ref.jpg");

const res = await fetch("http://localhost:5001/api/v1/images/with-refs", {
  method: "POST",
  headers: { "X-API-Key": process.env.FASTAFF_KEY },
  body: form,
});
const job = await res.json();
console.log(job.jobId, job.status, job.imageUrl);
```

#### Python (requests)
```python
import os, requests

files = [
    ("images", ("style-ref.png", open("style-ref.png", "rb"), "image/png")),
    ("images", ("pose-ref.jpg", open("pose-ref.jpg", "rb"), "image/jpeg")),
]
data = {
    "prompt": "a cyberpunk corgi riding a neon skateboard, studio lighting",
    "model": "HARBOR_SEAL",
    "aspectRatio": "IMAGE_ASPECT_RATIO_LANDSCAPE",
    "seed": "4242",
    "profileId": "prof_main_01",
    "libraryIds": "12",
}
r = requests.post(
    "http://localhost:5001/api/v1/images/with-refs",
    headers={"X-API-Key": os.environ["FASTAFF_KEY"]},
    data=data, files=files, timeout=180,
)
r.raise_for_status()
job = r.json()
print(job["jobId"], job["status"], job["imageUrl"])
```

#### C# (HttpClient)
```csharp
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5001") };
http.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("FASTAFF_KEY"));

using var form = new MultipartFormDataContent();
form.Add(new StringContent("a cyberpunk corgi riding a neon skateboard, studio lighting"), "prompt");
form.Add(new StringContent("HARBOR_SEAL"), "model");
form.Add(new StringContent("IMAGE_ASPECT_RATIO_LANDSCAPE"), "aspectRatio");
form.Add(new StringContent("4242"), "seed");
form.Add(new StringContent("prof_main_01"), "profileId");
form.Add(new StringContent("12"), "libraryIds");

var png = new ByteArrayContent(File.ReadAllBytes("style-ref.png"));
png.Headers.ContentType = new("image/png");
form.Add(png, "images", "style-ref.png");

var jpg = new ByteArrayContent(File.ReadAllBytes("pose-ref.jpg"));
jpg.Headers.ContentType = new("image/jpeg");
form.Add(jpg, "images", "pose-ref.jpg");

var res = await http.PostAsync("/api/v1/images/with-refs", form);
var body = await res.Content.ReadAsStringAsync();
Console.WriteLine($"{(int)res.StatusCode} {body}");
```

---

## Videos

> ℹ **Phase 46 (2026-06-01) — Mode snapshot at INSERT:** Mọi job khi tạo sẽ snapshot `captcha_mode` + `proxy_mode` từ profile lúc đó vào job row. Field `captchaMode` và `proxyMode` trong response phản ánh gen-time truth, không phải live profile state. Đổi cấu hình profile sau khi job tạo sẽ KHÔNG ảnh hưởng badge này trong UI.


Video generation dùng Veo3 / Veo3.1 qua Google Labs. Pipeline **luôn bất
đồng bộ** vì một clip mất vài phút — `POST` luôn trả `202 Accepted` +
`jobId` ngay lập tức.

### Luồng xử lý

```
POST /api/v1/videos   →  202 + jobId
                          ↓ (background worker)
                       submit tới Veo API → nhận operationName
                          ↓ (worker poll định kỳ)
                       Veo báo done → download mp4 → lưu file
                          ↓ (nếu có webhookUrl)
                       POST callback tới webhookUrl
```

Poll trạng thái: `GET /api/v1/videos/{jobId}`.

**Status values:**

| Status | Ý nghĩa |
|---|---|
| `queued` | Đã nhận, chờ worker |
| `running` | Worker đang submit lên Veo API |
| `polling` | Operation đã submit, worker đang poll kết quả từ Veo |
| `succeeded` | Xong — `videoUrl` có giá trị |
| `failed` | Lỗi — xem `error` |

### Bảng Video Model Key

| Model | 4s | 6s | 8s | 10s |
|---|---|---|---|---|
| Omni Flash | `abra_t2v_4s` | `abra_t2v_6s` | `abra_t2v_8s` | `abra_t2v_10s` |
| Veo 3.1 Lite | `veo_3_1_t2v_lite_4s` | `veo_3_1_t2v_lite_6s` | `veo_3_1_t2v_lite` | — |
| Veo 3.1 Fast | `veo_3_1_t2v_fast_4s` | `veo_3_1_t2v_fast_6s` | `veo_3_1_t2v_fast_portrait_ultra` (9:16) / `veo_3_1_t2v_fast_ultra` (16:9) | — |
| Veo 3.1 Lite (Low Priority) | `veo_3_1_t2v_lite_4s_low_priority` | `veo_3_1_t2v_lite_6s_low_priority` | `veo_3_1_t2v_lite_low_priority` | — |

**Aspect ratio cho video:** chỉ có 2 giá trị hợp lệ:
- `VIDEO_ASPECT_RATIO_PORTRAIT` — 9:16 (dọc)
- `VIDEO_ASPECT_RATIO_LANDSCAPE` — 16:9 (ngang)

> Profile dùng để gen video **phải** có tag `video`. Veo3 yêu cầu tài
> khoản Google trả phí.

---

### `POST /api/v1/videos`

Enqueue 1 video generation job. Luôn trả `202 Accepted`.

**Request body** — `GenerateVideoRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `prompt` | string | ✓ | Prompt text, 1-32768 ký tự |
| `aspectRatio` | string | | `VIDEO_ASPECT_RATIO_PORTRAIT` (9:16) hoặc `VIDEO_ASPECT_RATIO_LANDSCAPE` (16:9) |
| `videoModelKey` | string | | Key model từ bảng trên, tối đa 64 ký tự. Để trống = server chọn default |
| `seed` | int | | Seed tuỳ chọn |
| `profileId` | string | | Để trống = round-robin profile có tag `video` |
| `webhookUrl` | string | | URL callback khi job xong (http/https, tối đa 2048 ký tự) |

**Response 202** — `EnqueuedVideoResponse`:
```json
{
  "jobId": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4",
  "status": "queued",
  "webhookSecret": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}
```
`webhookSecret` chỉ có giá trị (không `null`) khi gửi kèm `webhookUrl`.
Dùng để xác thực callback: HMAC SHA-256 body bằng secret này phải khớp
header `X-FastAff-Signature` trong request callback.

**Response 400** — validation fail:
```json
{ "error": "Webhook URL must use http or https.", "reason": "invalid_webhook_url" }
```

> **`profileId`** — tuỳ chọn. Truyền vào → dùng đúng profile đó. Bỏ trống →
> server tự chọn (round-robin) trong các profile có tag `video`. Các ví dụ
> dưới minh hoạ kèm `profileId` — thay `PASTE_PROFILE_ID` bằng id thật
> (lấy ở tab Profiles hoặc `GET /api/v1/profiles`).

#### curl
```bash
curl -X POST http://localhost:5001/api/v1/videos \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "a cat chasing a butterfly in a garden, cinematic slow motion",
    "aspectRatio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
    "videoModelKey": "veo_3_1_t2v_fast_6s",
    "profileId": "PASTE_PROFILE_ID"
  }'
```

#### curl (với webhook)
```bash
curl -X POST http://localhost:5001/api/v1/videos \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "a cat chasing a butterfly in slow motion",
    "videoModelKey": "veo_3_1_t2v_lite_4s",
    "profileId": "PASTE_PROFILE_ID",
    "webhookUrl": "https://my-server.com/hooks/video-done"
  }'
```

#### JavaScript
```javascript
const r = await fetch('http://localhost:5001/api/v1/videos', {
  method: 'POST',
  headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    prompt: 'a cat chasing a butterfly in slow motion',
    aspectRatio: 'VIDEO_ASPECT_RATIO_LANDSCAPE',
    videoModelKey: 'veo_3_1_t2v_fast_6s',
    profileId: 'PASTE_PROFILE_ID',   // tuỳ chọn — bỏ field này = round-robin
  }),
});
// r.status === 202
const { jobId } = await r.json();
```

#### Python
```python
r = requests.post(
    "http://localhost:5001/api/v1/videos",
    headers={"X-API-Key": API_KEY},
    json={
        "prompt": "a cat chasing a butterfly in slow motion",
        "aspectRatio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
        "videoModelKey": "veo_3_1_t2v_fast_6s",
        "profileId": "PASTE_PROFILE_ID",  # tuỳ chọn — bỏ field này = round-robin
    },
)
assert r.status_code == 202
job_id = r.json()["jobId"]
```

#### C#
```csharp
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

var resp = await http.PostAsJsonAsync("http://localhost:5001/api/v1/videos", new
{
    prompt = "a cat chasing a butterfly in slow motion",
    aspectRatio = "VIDEO_ASPECT_RATIO_LANDSCAPE",
    videoModelKey = "veo_3_1_t2v_fast_6s",
    profileId = "PASTE_PROFILE_ID",   // tuỳ chọn — bỏ field này = round-robin
});
// resp.StatusCode == HttpStatusCode.Accepted (202)
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var jobId = doc.GetProperty("jobId").GetString();
```

---

### `GET /api/v1/videos/{jobId}`

Lấy trạng thái + metadata của 1 video job. Poll endpoint này để biết khi
nào `status` chuyển sang `succeeded` (hoặc `failed`).

**Response 200** — `VideoJobResponse`:
```json
{
  "jobId": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4",
  "profileId": "p1q2r3s4t5u6v7w8x9y0z1a2b3c4d5e6",
  "status": "succeeded",
  "prompt": "a cat chasing a butterfly in slow motion",
  "aspectRatio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
  "videoModelKey": "veo_3_1_t2v_fast_6s",
  "seed": null,
  "videoUrl": "http://localhost:5001/files/video/20260521/f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4.mp4",
  "error": null,
  "createdAt": "2026-05-21T10:00:00Z",
  "completedAt": "2026-05-21T10:03:45Z"
}
```

```bash
curl http://localhost:5001/api/v1/videos/f1e2d3c4 -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `GET /api/v1/videos`

Danh sách video job gần đây, sort `createdAt DESC`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `limit` | int | 50 | Số kết quả, 1-500 |

```bash
curl "http://localhost:5001/api/v1/videos?limit=20" -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/videos/{jobId}/file`

Tải file mp4 (auth-gated). Static mount `/files/` cũng phục vụ cùng file.

```bash
curl http://localhost:5001/api/v1/videos/f1e2d3c4/file \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o video.mp4
```

**Status codes:** `200 OK` (`video/mp4`) | `404 Not Found`

---

### `GET /api/v1/videos/{jobId}/logs`

Live-log buffer của 1 video job. UI gọi mỗi ~1s khi job đang ở trạng thái
`running` hoặc `polling`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `since` | int | 0 | Chỉ trả về các dòng từ index N trở đi |

**Response 200**:
```json
[
  {
    "ts": "2026-05-21T10:00:05.000Z",
    "level": "Information",
    "message": "Submitting to Veo API..."
  },
  {
    "ts": "2026-05-21T10:00:08.500Z",
    "level": "Information",
    "message": "Operation submitted: operations/abc123. Polling..."
  }
]
```

```bash
curl "http://localhost:5001/api/v1/videos/f1e2d3c4/logs?since=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `DELETE /api/v1/videos/{jobId}`

Huỷ 1 video job đang ở trạng thái `queued`. Worker đã claim job (status
`running`/`polling`) → KHÔNG huỷ được, trả 409.

**Response 204 No Content** khi huỷ thành công.

**Response 409 Conflict**:
```json
{
  "error": "Cannot cancel — job is 'running'. Only 'queued' jobs can be cancelled.",
  "reason": "not_cancellable"
}
```

**Response 404** khi không tồn tại jobId.

```bash
curl -X DELETE http://localhost:5001/api/v1/videos/f1e2d3c4 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `204 No Content` | `404 Not Found` | `409 Conflict`

---

### `POST /api/v1/videos/cancel-queued`

**Yêu cầu admin key.** Bulk-cancel tất cả video job đang `queued`. Dùng
để drain queue trước maintenance hoặc khi user flood queue.

**Response 200**:
```json
{ "cancelled": 7 }
```

```bash
curl -X POST http://localhost:5001/api/v1/videos/cancel-queued \
  -H "X-API-Key: $ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden` (không phải admin key)

---

---

### `GET /api/v1/videos/{jobId}/thumbnail`

Serve cached JPEG thumbnail (poster frame) cho video MP4 đã generate. Lần đầu request sẽ extract qua `ffmpeg` (seek 0.5s, scale 480px width, quality 5) rồi cache xuống `{GeneratedImagesPath}/thumbnails/{jobId}.jpg`; các lần sau là pure disk read. Dùng cho Gallery video grid và preview trong UI. Yêu cầu `ffmpeg` trên PATH (đã cài sẵn cả VPS A + B).

**Status codes:** `200 OK` (`image/jpeg`) | `404 Not Found` (job không tồn tại, `VideoPath` rỗng, file MP4 missing, hoặc API key không có quyền profile) | `502 Bad Gateway` (ffmpeg fail / video corrupt) | `504 Gateway Timeout` (ffmpeg quá 15s)

**Response headers:**
- `Content-Type: image/jpeg`
- `Cache-Control: public, max-age=86400, immutable` (thumb derive từ MP4 immutable nên long-cache an toàn)

**Response 502/5xx** (rare — chỉ khi ffmpeg lỗi):
```json
{ "error": "ffmpeg extract failed" }
```

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  http://localhost:5001/api/v1/videos/vid_2026060201ab/thumbnail \
  -o thumb.jpg
```

---

---

## R2V — Reference-to-Video

> ℹ **Phase 46 (2026-06-01) — Mode snapshot at INSERT:** Mọi job khi tạo sẽ snapshot `captcha_mode` + `proxy_mode` từ profile lúc đó vào job row. Field `captchaMode` và `proxyMode` trong response phản ánh gen-time truth, không phải live profile state. Đổi cấu hình profile sau khi job tạo sẽ KHÔNG ảnh hưởng badge này trong UI.


R2V dùng Veo3 R2V model để **tạo video từ ảnh tham chiếu + prompt**. Khác
với Videos (text-to-video thuần), R2V cần upload 1+ ảnh tham chiếu lên
labs.google trước khi submit gen. Pipeline async, ~3-5 phút tổng cho 1
clip.

### Luồng xử lý

```
POST /api/v1/r2v (multipart)  →  202 + jobId
                                  ↓ (worker tick)
                               UPLOAD ảnh → mediaIds
                                  ↓
                               CAPTCHA solve
                                  ↓
                               SUBMIT gen (mediaIds + prompt + model)
                                  ↓ (worker poll định kỳ)
                               POLL operation cho tới khi xong
                                  ↓
                               DOWNLOAD mp4 → lưu file
                                  ↓ (nếu có webhookUrl)
                               POST callback tới webhookUrl
```

Poll trạng thái: `GET /api/v1/r2v/{jobId}`.

**Status values:**

| Status | Ý nghĩa |
|---|---|
| `queued` | Đã nhận, chờ worker |
| `running` | Worker đang upload ảnh + giải captcha + submit gen |
| `polling` | Operation đã submit, worker đang poll kết quả từ Veo |
| `succeeded` | Xong — `videoUrl` có giá trị |
| `failed` | Lỗi — xem `error` |

### Bảng R2V Model Key

| Model | Key |
|---|---|
| Veo 3.1 R2V Lite (Low Priority) — default | `veo_3_1_r2v_lite_low_priority` |

> Hiện chỉ có 1 model key R2V được verified production. Có thể truyền key
> khác qua field `videoModelKey` nếu Google bổ sung model mới — server
> không validate (chỉ forward).

**Aspect ratio:** giống Videos (`VIDEO_ASPECT_RATIO_LANDSCAPE` /
`VIDEO_ASPECT_RATIO_PORTRAIT`).

### Yêu cầu profile

- Tag `video` (giống Videos)
- `projectId` đã được set (UUID của Google labs project, lấy từ DevTools
  khi đang ở labs.google) — R2V FAIL ngay với reason `profile_no_project_id`
  nếu thiếu

### Định dạng ảnh tham chiếu

| Mime type | Hỗ trợ |
|---|---|
| `image/jpeg`, `image/jpg` | ✓ |
| `image/png` | ✓ |
| `image/webp` | ✓ |
| Khác | ✗ → 400 `invalid_mime` |

Không giới hạn cứng số ảnh hoặc dung lượng từng ảnh. Soft warning khi
**tổng** dung lượng > 20 MB (log warning, không block — server vẫn nhận).
Request size limit cứng: **200 MB**.

> Ảnh được upload song song (3 ảnh cùng lúc) lên labs.google để giảm
> latency. Nếu Google reject quá nhiều ảnh (vd phát hiện duplicate
> account abuse) → 1 trong các upload sẽ fail và toàn job fail với
> `error` chứa message từ Google.

---

### `POST /api/v1/r2v`

Submit 1 R2V job. **Content-Type bắt buộc là `multipart/form-data`**
(KHÔNG phải `application/json` như Videos).

**Form fields:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `prompt` | string | ✓ | Prompt text, 1-32768 ký tự |
| `images` | file | ✓* | 1+ file ảnh (gửi nhiều `-F "images=@..."` cho nhiều ảnh). Mime hợp lệ: jpeg/png/webp. *Bắt buộc TRỪ KHI đã cung cấp `libraryIds` |
| `libraryIds` | string | ✓* | CSV id ảnh trong Media Library (vd `12,15,18`) — dùng lại ảnh đã upload, không cần gửi file. *Bắt buộc TRỪ KHI đã gửi `images`. Có thể kết hợp cả hai |
| `aspectRatio` | string | | `VIDEO_ASPECT_RATIO_LANDSCAPE` (default) hoặc `VIDEO_ASPECT_RATIO_PORTRAIT` |
| `videoModelKey` | string | | R2V model key (xem bảng trên). Để trống = server chọn default |
| `seed` | int | | Seed tuỳ chọn (5 chữ số). Để trống = auto random |
| `profileId` | string | | Để trống = round-robin profile có tag `video` |
| `webhookUrl` | string | | URL callback (http/https) khi job xong, tối đa 2048 ký tự |

> Phải có ít nhất một nguồn ảnh: **`images`** (upload) HOẶC **`libraryIds`** (chọn từ thư viện). `referenceCount` trong response = tổng số ảnh từ cả hai nguồn.

**Response 202** — `EnqueuedR2VResponse`:
```json
{
  "jobId": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4",
  "status": "queued",
  "webhookSecret": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "referenceCount": 2
}
```

`webhookSecret` chỉ có giá trị khi gửi kèm `webhookUrl`.

**Response 400** — validation fail:
```json
{ "error": "at least one image required — upload via 'images' field OR pick from library via 'libraryIds'", "reason": "no_images" }
```
```json
{ "error": "file 'foo.bmp' has unsupported mime 'image/bmp'. Allowed: image/jpeg, image/jpg, image/png, image/webp", "reason": "invalid_mime" }
```
```json
{ "error": "Profile abc123 has no project_id. Set it under Profiles → Edit before using R2V.", "reason": "profile_no_project_id" }
```

#### curl
```bash
curl -X POST http://localhost:5001/api/v1/r2v \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=khỉ ăn quả hồng trên cây, ánh nắng vàng cinematic" \
  -F "aspectRatio=VIDEO_ASPECT_RATIO_LANDSCAPE" \
  -F "videoModelKey=veo_3_1_r2v_lite_low_priority" \
  -F "profileId=PASTE_PROFILE_ID" \
  -F "images=@./ref1.jpg" \
  -F "images=@./ref2.jpg"
```

#### curl (với webhook)
```bash
curl -X POST http://localhost:5001/api/v1/r2v \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=cô gái cầm hoa đi giữa rừng" \
  -F "images=@./ref1.jpg" \
  -F "webhookUrl=https://my-server.com/hooks/r2v-done"
```

#### JavaScript
```javascript
const fd = new FormData();
fd.append('prompt', 'a monkey eating persimmon, cinematic');
fd.append('aspectRatio', 'VIDEO_ASPECT_RATIO_LANDSCAPE');
fd.append('profileId', 'PASTE_PROFILE_ID');
// File từ <input type=file> hoặc Blob từ canvas
for (const file of fileInput.files) fd.append('images', file);

const r = await fetch('http://localhost:5001/api/v1/r2v', {
  method: 'POST',
  headers: { 'X-API-Key': API_KEY },   // KHÔNG set Content-Type — browser tự set boundary
  body: fd,
});
const { jobId } = await r.json();
```

#### Python
```python
import requests

files = [
    ('images', ('ref1.jpg', open('ref1.jpg', 'rb'), 'image/jpeg')),
    ('images', ('ref2.jpg', open('ref2.jpg', 'rb'), 'image/jpeg')),
]
data = {
    'prompt': 'a monkey eating persimmon, cinematic',
    'aspectRatio': 'VIDEO_ASPECT_RATIO_LANDSCAPE',
    'profileId': 'PASTE_PROFILE_ID',
}
r = requests.post(
    'http://localhost:5001/api/v1/r2v',
    headers={'X-API-Key': API_KEY},
    files=files,
    data=data,
)
assert r.status_code == 202
job_id = r.json()['jobId']
```

#### C#
```csharp
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

using var form = new MultipartFormDataContent();
form.Add(new StringContent("a monkey eating persimmon, cinematic"), "prompt");
form.Add(new StringContent("VIDEO_ASPECT_RATIO_LANDSCAPE"), "aspectRatio");

foreach (var path in new[] { "ref1.jpg", "ref2.jpg" })
{
    var bytes = await File.ReadAllBytesAsync(path);
    var fileContent = new ByteArrayContent(bytes);
    fileContent.Headers.ContentType = new("image/jpeg");
    form.Add(fileContent, "images", Path.GetFileName(path));
}

var resp = await http.PostAsync("http://localhost:5001/api/v1/r2v", form);
// resp.StatusCode == HttpStatusCode.Accepted (202)
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var jobId = doc.GetProperty("jobId").GetString();
```

---

### `GET /api/v1/r2v/{jobId}`

Lấy trạng thái + metadata của 1 R2V job. Poll endpoint này để biết khi
nào `status` chuyển sang `succeeded` (hoặc `failed`).

**Response 200** — `R2VJobResponse`:
```json
{
  "jobId": "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4",
  "profileId": "p1q2r3s4t5u6v7w8x9y0z1a2b3c4d5e6",
  "status": "succeeded",
  "prompt": "khỉ ăn quả hồng trên cây",
  "aspectRatio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
  "videoModelKey": "veo_3_1_r2v_lite_low_priority",
  "seed": 13668,
  "videoUrl": "http://localhost:5001/files/r2v/20260527/f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4.mp4",
  "error": null,
  "createdAt": "2026-05-27T10:00:00Z",
  "completedAt": "2026-05-27T10:04:12Z",
  "referenceCount": 2,
  "referenceFileNames": ["ref1.jpg", "ref2.jpg"]
}
```

```bash
curl http://localhost:5001/api/v1/r2v/f1e2d3c4 -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `GET /api/v1/r2v`

Danh sách R2V job gần đây, sort `createdAt DESC`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `limit` | int | 50 | Số kết quả, 1-500 |

**Response 200** — `R2VJobResponse[]`.

```bash
curl "http://localhost:5001/api/v1/r2v?limit=20" -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/r2v/{jobId}/file`

Tải file mp4 đầu ra (auth-gated). Static mount `/files/r2v/...` cũng
phục vụ cùng file.

```bash
curl http://localhost:5001/api/v1/r2v/f1e2d3c4/file \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o output.mp4
```

**Status codes:** `200 OK` (`video/mp4`) | `404 Not Found`

---

### `GET /api/v1/r2v/{jobId}/ref/{idx}`

Tải ảnh tham chiếu gốc theo index (0-based). UI dùng để hiển thị
thumbnail trong bảng Recent jobs.

**Path params:**

| Param | Type | Mô tả |
|---|---|---|
| `jobId` | string | Id của job R2V |
| `idx` | int | Index ảnh, 0-based (0, 1, 2, ...) |

Content-Type trả về khớp mime đã upload (jpeg/png/webp).

```bash
curl http://localhost:5001/api/v1/r2v/f1e2d3c4/ref/0 \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o ref0.jpg
```

**Status codes:** `200 OK` | `404 Not Found` (job hoặc idx không tồn tại)

---

### `GET /api/v1/r2v/{jobId}/logs`

Live-log buffer của 1 R2V job. UI gọi mỗi ~1.5s khi job đang chạy. Schema
giống `/api/v1/videos/{jobId}/logs`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `since` | int | 0 | Chỉ trả về các dòng từ index N trở đi |

**Response 200**:
```json
[
  {
    "ts": "2026-05-27T10:00:01.000Z",
    "level": "Information",
    "message": "[R2VUPLOAD abc12345] uploading 1/2 ref1.jpg (450KB)"
  },
  {
    "ts": "2026-05-27T10:00:03.500Z",
    "level": "Information",
    "message": "[R2V-UPLOAD] accepted file=ref1.jpg mediaId=d0c7e973-..."
  }
]
```

```bash
curl "http://localhost:5001/api/v1/r2v/f1e2d3c4/logs?since=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `DELETE /api/v1/r2v/{jobId}`

Huỷ 1 R2V job đang `queued`. Giống `DELETE /api/v1/videos/{jobId}` —
chỉ huỷ được khi worker chưa claim.

**Response 204 No Content** khi huỷ thành công.

**Response 409 Conflict**:
```json
{
  "error": "Cannot cancel — job is 'polling'. Only 'queued' jobs can be cancelled.",
  "reason": "not_cancellable"
}
```

```bash
curl -X DELETE http://localhost:5001/api/v1/r2v/f1e2d3c4 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `204 No Content` | `404 Not Found` | `409 Conflict`

---

### `POST /api/v1/r2v/cancel-queued`

**Yêu cầu admin key.** Bulk-cancel tất cả R2V job đang `queued`.

**Response 200**:
```json
{ "cancelled": 3 }
```

```bash
curl -X POST http://localhost:5001/api/v1/r2v/cancel-queued \
  -H "X-API-Key: $ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden`

---

---

### `GET /api/v1/r2v/{jobId}/thumbnail`

Cùng shape với Videos thumbnail nhưng cho R2V job (reference-to-video). Cache file đặt tại `{GeneratedImagesPath}/thumbnails/r2v-{jobId}.jpg` (prefix `r2v-` để tránh đụng namespace với Videos jobIds). F2V job dùng endpoint riêng cùng pattern.

**Status codes:** `200 OK` (`image/jpeg`) | `404 Not Found` | `502 Bad Gateway` | `504 Gateway Timeout`

**Response headers:** giống Videos thumbnail (`Cache-Control: public, max-age=86400, immutable`).

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  http://localhost:5001/api/v1/r2v/r2v_2026060218cd/thumbnail \
  -o thumb.jpg
```

---

---

## F2V — Frame-to-Video

> ℹ **Phase 46 (2026-06-01) — Mode snapshot at INSERT:** Mọi job khi tạo sẽ snapshot `captcha_mode` + `proxy_mode` từ profile lúc đó vào job row. Field `captchaMode` và `proxyMode` trong response phản ánh gen-time truth, không phải live profile state. Đổi cấu hình profile sau khi job tạo sẽ KHÔNG ảnh hưởng badge này trong UI.

Frame-to-Video tạo video qua Veo từ start+end image; pipeline bất đồng bộ (similar R2V). Phase 47 (2026-06-01).

F2V dùng Veo3 frame-interpolation endpoint (`batchAsyncGenerateVideoStartAndEndImage`) để **dựng 1 clip giữa 2 ảnh khoá**: 1 ảnh `startImage` + 1 ảnh `endImage` + 1 prompt mô tả chuyển động. Mirror cấu trúc R2V nhưng input cố định là **đúng 2 file** thay vì N file tham chiếu. Pipeline async, ~3-5 phút tổng cho 1 clip.

### Luồng xử lý

```
POST /api/v1/f2v (multipart)  →  202 + jobId
                                  ↓ (worker tick)
                               UPLOAD startImage + endImage → mediaIds
                                  ↓
                               CAPTCHA solve
                                  ↓
                               SUBMIT gen (startMediaId + endMediaId + prompt + model)
                                  ↓ (worker poll định kỳ)
                               POLL operation cho tới khi xong
                                  ↓
                               DOWNLOAD mp4 → lưu file
                                  ↓ (nếu có webhookUrl)
                               POST callback tới webhookUrl
```

Poll trạng thái: `GET /api/v1/f2v/{jobId}`.

**Status values:**

| Status | Ý nghĩa |
|---|---|
| `queued` | Đã nhận, chờ worker |
| `running` | Worker đang upload 2 frame + giải captcha + submit gen |
| `polling` | Operation đã submit, worker đang poll kết quả từ Veo |
| `succeeded` | Xong — `videoUrl` có giá trị |
| `failed` | Lỗi — xem `error` |

### Yêu cầu profile

- Tag `video` (giống Videos / R2V)
- `projectId` đã được set (UUID của Google labs project) — F2V FAIL với reason `profile_no_project_id` nếu thiếu

### Định dạng frame

| Mime type | Hỗ trợ |
|---|---|
| `image/jpeg`, `image/jpg` | ✓ |
| `image/png` | ✓ |
| `image/webp` | ✓ |
| Khác | ✗ → 400 `invalid_mime` |

Request size limit cứng: **50 MB** (2 file ảnh, mỗi file ~25 MB max). Soft warning khi tổng dung lượng vượt ngưỡng cấu hình `F2VOptions.SoftTotalBytesWarn`.

---

### `POST /api/v1/f2v`

Submit 1 F2V job. **Content-Type bắt buộc là `multipart/form-data`** (giống R2V). Trả về `202 Accepted` + `jobId` ngay — kết quả lấy qua poll hoặc webhook.

**Form fields:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `startImage` | file | ✓ | Ảnh frame đầu (1 file). Mime hợp lệ: jpeg/png/webp |
| `endImage` | file | ✓ | Ảnh frame cuối (1 file). Mime hợp lệ: jpeg/png/webp |
| `prompt` | string | | Mô tả chuyển động giữa 2 frame, ≤32768 ký tự. Để trống = default `"create video"` |
| `aspectRatio` | string | | `VIDEO_ASPECT_RATIO_LANDSCAPE` (default) hoặc `VIDEO_ASPECT_RATIO_PORTRAIT` |
| `videoModelKey` | string | | Veo model key (giống Videos/R2V). Để trống = server chọn default |
| `seed` | int | | Seed tuỳ chọn. Để trống = auto random |
| `profileId` | string | | Để trống = round-robin profile có tag `video` |
| `webhookUrl` | string | | URL callback (http/https) khi job xong, ≤2048 ký tự |

**Response 202** — `EnqueuedF2VResponse`:
```json
{
  "jobId": "a7b3c1d2e4f5a6b7c8d9e0f1a2b3c4d5",
  "status": "queued",
  "webhookSecret": "9e8d7c6b5a493827a1b2c3d4e5f60718"
}
```

`webhookSecret` chỉ có giá trị khi gửi kèm `webhookUrl`.

**Response 400** — validation fail:
```json
{ "error": "startImage file required", "reason": "no_start_image" }
```
```json
{ "error": "endImage file required", "reason": "no_end_image" }
```
```json
{ "error": "startImage has unsupported mime 'image/bmp'. Allowed: image/jpeg, image/jpg, image/png, image/webp", "reason": "invalid_mime" }
```
```json
{ "error": "prompt ≤32768 chars", "reason": "invalid_prompt" }
```
```json
{ "error": "multipart/form-data required", "reason": "bad_content_type" }
```

**Response 403** — API key không có quyền dùng `profileId`:
```json
{ "error": "API key not allowed to use this profile.", "reason": "profile_scope" }
```

#### curl
```bash
curl -X POST http://localhost:5001/api/v1/f2v \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=cô gái quay người mỉm cười, gió thổi nhẹ" \
  -F "aspectRatio=VIDEO_ASPECT_RATIO_LANDSCAPE" \
  -F "profileId=PASTE_PROFILE_ID" \
  -F "startImage=@./frame_start.jpg" \
  -F "endImage=@./frame_end.jpg"
```

#### curl (với webhook)
```bash
curl -X POST http://localhost:5001/api/v1/f2v \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "prompt=camera dolly in toward subject" \
  -F "startImage=@./a.jpg" \
  -F "endImage=@./b.jpg" \
  -F "webhookUrl=https://my-server.com/hooks/f2v-done"
```

#### JavaScript
```javascript
const fd = new FormData();
fd.append('prompt', 'a flower blooming from bud to full bloom');
fd.append('aspectRatio', 'VIDEO_ASPECT_RATIO_LANDSCAPE');
fd.append('profileId', 'PASTE_PROFILE_ID');
fd.append('startImage', startFileInput.files[0]);
fd.append('endImage', endFileInput.files[0]);

const r = await fetch('http://localhost:5001/api/v1/f2v', {
  method: 'POST',
  headers: { 'X-API-Key': API_KEY },   // KHÔNG set Content-Type — browser tự set boundary
  body: fd,
});
const { jobId } = await r.json();
```

#### Python
```python
import requests

files = {
    'startImage': ('start.jpg', open('start.jpg', 'rb'), 'image/jpeg'),
    'endImage':   ('end.jpg',   open('end.jpg',   'rb'), 'image/jpeg'),
}
data = {
    'prompt': 'a flower blooming from bud to full bloom',
    'aspectRatio': 'VIDEO_ASPECT_RATIO_LANDSCAPE',
    'profileId': 'PASTE_PROFILE_ID',
}
r = requests.post(
    'http://localhost:5001/api/v1/f2v',
    headers={'X-API-Key': API_KEY},
    files=files,
    data=data,
)
assert r.status_code == 202
job_id = r.json()['jobId']
```

#### C#
```csharp
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", apiKey);

using var form = new MultipartFormDataContent();
form.Add(new StringContent("a flower blooming from bud to full bloom"), "prompt");
form.Add(new StringContent("VIDEO_ASPECT_RATIO_LANDSCAPE"), "aspectRatio");

var startBytes = await File.ReadAllBytesAsync("start.jpg");
var startContent = new ByteArrayContent(startBytes);
startContent.Headers.ContentType = new("image/jpeg");
form.Add(startContent, "startImage", "start.jpg");

var endBytes = await File.ReadAllBytesAsync("end.jpg");
var endContent = new ByteArrayContent(endBytes);
endContent.Headers.ContentType = new("image/jpeg");
form.Add(endContent, "endImage", "end.jpg");

var resp = await http.PostAsync("http://localhost:5001/api/v1/f2v", form);
// resp.StatusCode == HttpStatusCode.Accepted (202)
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var jobId = doc.GetProperty("jobId").GetString();
```

---

### `GET /api/v1/f2v/{jobId}`

Lấy trạng thái + metadata của 1 F2V job. Poll endpoint này để biết khi nào `status` chuyển sang `succeeded` (hoặc `failed`).

**Response 200** — `F2VJobResponse`:
```json
{
  "jobId": "a7b3c1d2e4f5a6b7c8d9e0f1a2b3c4d5",
  "profileId": "p1q2r3s4t5u6v7w8x9y0z1a2b3c4d5e6",
  "status": "succeeded",
  "prompt": "cô gái quay người mỉm cười, gió thổi nhẹ",
  "aspectRatio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
  "videoModelKey": "veo_3_1_fast_t2v",
  "seed": 42178,
  "videoUrl": "http://localhost:5001/files/f2v/20260601/a7b3c1d2e4f5a6b7c8d9e0f1a2b3c4d5.mp4",
  "error": null,
  "createdAt": "2026-06-01T21:16:00Z",
  "completedAt": "2026-06-01T21:20:14Z",
  "startFrameFileName": "frame_start.jpg",
  "endFrameFileName": "frame_end.jpg",
  "outboundIp": "203.0.113.42",
  "captchaMode": "auto",
  "proxyMode": "rotation"
}
```

> `startFrameFileName` / `endFrameFileName` là tên file gốc đã upload — UI dùng để hiển thị label. File ảnh thực tế phục vụ qua endpoint riêng `GET /api/v1/f2v/{jobId}/frame/{role}` (xem dưới).

```bash
curl http://localhost:5001/api/v1/f2v/a7b3c1d2 -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `GET /api/v1/f2v`

Danh sách F2V job gần đây, sort `createdAt DESC`. Scope theo `AllowedProfileIds` của API key (nếu key bị giới hạn profile).

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `limit` | int | 50 | Số kết quả, 1-500 (ngoài khoảng → reset về 50) |
| `offset` | int | 0 | Bỏ qua N kết quả đầu (paging) |

**Response 200** — `F2VJobResponse[]`.

```bash
curl "http://localhost:5001/api/v1/f2v?limit=20&offset=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/f2v/{jobId}/logs`

Live-log buffer của 1 F2V job. UI gọi mỗi ~1.5s khi job đang chạy. Schema giống `/api/v1/r2v/{jobId}/logs`.

**Query params:**

| Param | Type | Default | Mô tả |
|---|---|---|---|
| `since` | int | 0 | Chỉ trả về các dòng từ index N trở đi |

**Response 200**:
```json
[
  {
    "ts": "2026-06-01T21:16:01.000Z",
    "level": "Information",
    "message": "[F2V-UPLOAD a7b3c1d2] uploading start frame (520KB)"
  },
  {
    "ts": "2026-06-01T21:16:03.500Z",
    "level": "Information",
    "message": "[F2V-UPLOAD] accepted file=frame_start.jpg mediaId=d0c7e973-..."
  }
]
```

```bash
curl "http://localhost:5001/api/v1/f2v/a7b3c1d2/logs?since=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `DELETE /api/v1/f2v/{jobId}`

Huỷ 1 F2V job đang `queued`. Giống `DELETE /api/v1/r2v/{jobId}` — chỉ huỷ được khi worker chưa claim.

**Response 204 No Content** khi huỷ thành công.

**Response 409 Conflict**:
```json
{
  "error": "Cannot cancel — job is 'polling'. Only 'queued' jobs can be cancelled.",
  "reason": "not_cancellable"
}
```
```json
{
  "error": "Job was claimed by the worker between the check and the cancel.",
  "reason": "claim_race"
}
```

```bash
curl -X DELETE http://localhost:5001/api/v1/f2v/a7b3c1d2 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `204 No Content` | `404 Not Found` | `409 Conflict`

---

### `POST /api/v1/f2v/cancel-queued`

***Admin only.*** Bulk-cancel tất cả F2V job đang `queued`.

**Response 200**:
```json
{ "cancelled": 2 }
```

**Response 403**:
```json
{ "error": "Admin API key required." }
```

```bash
curl -X POST http://localhost:5001/api/v1/f2v/cancel-queued \
  -H "X-API-Key: $ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden`

---

### `GET /api/v1/f2v/{jobId}/file`

Tải file mp4 đầu ra (auth-gated). Static mount `/files/f2v/...` cũng phục vụ cùng file. Trả `404` nếu job chưa `succeeded` hoặc file đã bị xoá.

```bash
curl http://localhost:5001/api/v1/f2v/a7b3c1d2/file \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o output.mp4
```

**Status codes:** `200 OK` (`video/mp4`) | `404 Not Found`

---

### `GET /api/v1/f2v/{jobId}/thumbnail`

Trả về thumbnail JPEG 480px-wide trích từ mp4 (qua `ffmpeg -ss 0.5 -frames:v 1`). Cache trên disk ở `data/generated/thumbnails/f2v-{jobId}.jpg` — lần đầu mất 1-2s, các lần sau serve thẳng.

Response gắn header `Cache-Control: public, max-age=86400, immutable`.

```bash
curl http://localhost:5001/api/v1/f2v/a7b3c1d2/thumbnail \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o thumb.jpg
```

**Status codes:** `200 OK` (`image/jpeg`) | `404 Not Found` (job/video không tồn tại) | `502 Bad Gateway` (ffmpeg launch/extract fail) | `504 Gateway Timeout` (ffmpeg >15s)

---

### `GET /api/v1/f2v/{jobId}/frame/{role}`

Tải ảnh frame gốc đã upload (start hoặc end). UI dùng để hiển thị 2 thumbnail input trong bảng Recent jobs.

**Path params:**

| Param | Type | Mô tả |
|---|---|---|
| `jobId` | string | Id của job F2V |
| `role` | string | `start` hoặc `end` (case-insensitive). Giá trị khác → 404 |

Content-Type trả về khớp mime đã upload (jpeg/png/webp). Filename trong `Content-Disposition` là tên file gốc.

```bash
curl http://localhost:5001/api/v1/f2v/a7b3c1d2/frame/start \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o start.jpg

curl http://localhost:5001/api/v1/f2v/a7b3c1d2/frame/end \
  -H "X-API-Key: $FASTAFF_KEY" \
  -o end.jpg
```

**Status codes:** `200 OK` | `404 Not Found` (job hoặc role không hợp lệ, hoặc file đã bị xoá khỏi `data/f2v_uploads/`)

---

## Gallery

Gallery hợp nhất video từ 3 nguồn (Videos, R2V, F2V) cho UI Library — tiết kiệm việc client phải gọi 3 endpoint riêng. Phase 48.

---

### `GET /api/v1/gallery/videos`

Trả về danh sách video tổng hợp từ ba bảng `video_jobs` (T2V), `r2v_jobs` (R2V) và `f2v_jobs` (F2V) bằng `UNION ALL`, sắp xếp theo `created_at` giảm dần. Nếu API key bị giới hạn `AllowedProfileIds` thì kết quả được tự động lọc theo các profile được phép.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `limit` | int |  | Số dòng trả về, mặc định `50`, hợp lệ trong khoảng `1..500`. Giá trị ngoài khoảng sẽ bị reset về `50`. |
| `offset` | int |  | Bỏ qua N dòng đầu tiên (phân trang), mặc định `0`. Giá trị âm sẽ bị clamp về `0`. |

**Response 200** — `GalleryVideoRow[]`:
```json
[
  {
    "source": "t2v",
    "jobId": "vjob_01HZX9K3M2Q8YT5R4N6P7A1B2C",
    "prompt": "a golden retriever surfing at sunset, cinematic, 4k",
    "status": "completed",
    "createdAt": "2026-06-02T08:14:27Z",
    "aspectRatio": "16:9",
    "profileId": "prof_8a1f3c"
  },
  {
    "source": "r2v",
    "jobId": "r2v_01HZX8B0F7K3JTQX9D2M5N4P6E",
    "prompt": "make the cat dance to lo-fi beats",
    "status": "completed",
    "createdAt": "2026-06-02T07:52:11Z",
    "aspectRatio": "9:16",
    "profileId": "prof_8a1f3c"
  },
  {
    "source": "f2v",
    "jobId": "f2v_01HZX7QH5N8B2K9R3M1P4T6A0D",
    "prompt": "smooth interpolation: city dawn -> city dusk",
    "status": "running",
    "createdAt": "2026-06-02T07:33:48Z",
    "aspectRatio": "16:9",
    "profileId": "prof_2c4b9d"
  }
]
```

Mỗi dòng có trường `source` phân biệt nguồn (`"t2v"` | `"r2v"` | `"f2v"`) để UI route đúng controller khi cần lấy thumbnail / file / playback URL của job đó. Các field cụ thể như `videoUrl`, `thumbnailUrl`, `videoModelKey` không có trong feed này — phải gọi endpoint chi tiết của từng nguồn tương ứng (`/api/v1/videos/{id}`, `/api/v1/r2v/{id}`, `/api/v1/f2v/{id}`).

**Status codes:** `200 OK`

#### curl
```bash
curl "http://localhost:5001/api/v1/gallery/videos?limit=50&offset=0" \
  -H "X-API-Key: $FASTAFF_KEY"
```

### `GET /api/v1/gallery/categories?type={image|video}`

Sidebar feed — per-tag counts cho UI Gallery. Scope theo profile mà API key được phép access (per RBAC).

**Query params**: `type` (string, default `"image"`). Khi `"video"` → aggregate count qua **3 nguồn** (`video|r2v|f2v`) match feed `/gallery/videos`.

**Response 200**:
```json
[
  { "id": 1, "name": "ai-art",       "emoji": "🎨", "color": "purple", "sortOrder": 10, "count": 142 },
  { "id": 2, "name": "thumbnails",   "emoji": "🖼️", "color": "blue",   "sortOrder": 20, "count": 38  },
  { "id": 3, "name": "untagged",     "emoji": "❓",  "color": "gray",   "sortOrder": 999,"count": 12  }
]
```

Bao gồm cả category với `count=0` (UI vẫn hiển thị để user click).

```bash
curl "http://localhost:5001/api/v1/gallery/categories?type=video" \
  -H "X-API-Key: $FASTAFF_KEY"
```

### `POST /api/v1/gallery/ensure-tag`

Lazy ensure-tag — gọi sau Gallery search nếu thấy rows `untagged`. Tag 1 batch nhỏ (`≤ batchLazy` config GoClaw, default ~5) các job succeeded chưa có tag. Idempotent.

**Body**: không có.

**Response 200**:
```json
{ "ok": true, "tagged": 5 }
```

Hoặc khi GoClaw config chưa setup:
```json
{ "ok": false, "tagged": 0, "error": "GoClaw config incomplete." }
```

```bash
curl -X POST http://localhost:5001/api/v1/gallery/ensure-tag \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

## Library

Thư viện media theo từng profile. Lưu ảnh upload trực tiếp qua controller này
HOẶC auto-save khi ảnh được dùng làm reference trong R2V. Mỗi row mang
`mediaId` của Google — caller tái sử dụng nó trong R2V mà không cần upload lại
cùng 1 file.

### `POST /api/v1/library/upload`

Upload 1 ảnh vào thư viện. `multipart/form-data` với 1 field `file`. Profile
phải có `projectId` và bearer token. Mime hợp lệ: `image/jpeg`, `image/jpg`,
`image/png`, `image/webp`. Giới hạn 50 MB.

**Query:** `profileId` (bắt buộc).

**Response 200** — `LibraryItemResponse`:
```json
{
  "id": 42,
  "mediaId": "CAIaSAo...",
  "profileId": "abc123def456",
  "projectId": "40020ed1-3b52-47ec-9e9a-4c503dd35f0b",
  "fileName": "ref-01.png",
  "mimeType": "image/png",
  "sizeBytes": 184320,
  "width": 1024,
  "height": 1024,
  "uploadedAt": "2026-05-28T09:00:00Z",
  "lastUsedAt": null,
  "useCount": 0
}
```

```bash
curl -X POST "http://localhost:5001/api/v1/library/upload?profileId=abc123def456" \
  -H "X-API-Key: $FASTAFF_KEY" \
  -F "file=@ref-01.png"
```

**Status codes:** `200 OK` | `400 Bad Request` (thiếu profileId / sai content-type / mime không hỗ trợ / profile chưa có projectId / chưa có bearer / không có file) | `404 Not Found` (profile) | `502 Bad Gateway` (upload lên Google thất bại — body có `reason: "google_upload_failed"` + `statusCode`)

---

### `GET /api/v1/library`

List item của 1 profile (phân trang, mới nhất trước).

**Query:** `profileId` (bắt buộc), `limit` (mặc định 100), `offset` (mặc định 0).

**Response 200** — `LibraryListResponse`:
```json
{
  "totalCount": 12,
  "items": [ /* LibraryItemResponse[] */ ]
}
```

```bash
curl "http://localhost:5001/api/v1/library?profileId=abc123def456&limit=50" \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `200 OK` | `400 Bad Request` (thiếu `profileId`)

---

### `GET /api/v1/library/{id}`

Lấy metadata 1 item.

**Response 200** — `LibraryItemResponse`. | `404 Not Found`

---

### `GET /api/v1/library/{id}/file`

Stream ảnh full-size (auth-gated, giữ nguyên mime).

**Status codes:** `200 OK` (binary) | `404 Not Found`

---

### `GET /api/v1/library/{id}/thumb`

Thumbnail. Hiện trả full file (UI scale bằng CSS) — tương lai resize server-side.

**Status codes:** `200 OK` (binary) | `404 Not Found`

---

### `DELETE /api/v1/library/{id}`

Xoá item (xoá cả file local + row DB).

```bash
curl -X DELETE http://localhost:5001/api/v1/library/42 \
  -H "X-API-Key: $FASTAFF_KEY"
```

**Status codes:** `204 No Content` | `404 Not Found`

---

## Settings

Runtime config — thay đổi không cần restart server.

### `GET /api/v1/settings/captcha`

Effective captcha config sau khi merge `appsettings.json` + runtime overrides.

**Response 200** — `CaptchaSettingsResponse`:
```json
{
  "serverUrl": "http://captcha.example.com",
  "siteKey": "6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV",
  "actionName": "IMAGE_GENERATION",
  "websiteUrl": "https://labs.google/fx/vi/tools/flow",
  "timeoutSeconds": 60,
  "siteKeyOverridden": false,
  "actionOverridden": false,
  "serverUrlPerProfile": false
}
```

> Lưu ý: captcha action cho video generation là `VIDEO_GENERATION`, cho
> ảnh là `IMAGE_GENERATION`. Action được cấu hình server-side và có thể
> override qua `actionName`.

```bash
curl http://localhost:5001/api/v1/settings/captcha -H "X-API-Key: $FASTAFF_KEY"
```

---

### `PUT /api/v1/settings/captcha`

Set/clear runtime overrides. Empty string = clear override (về lại
appsettings). `null` = unchanged.

**Request body** — `UpdateCaptchaSettingsRequest`:

| Field | Type | Mô tả |
|---|---|---|
| `siteKey` | string | Override siteKey. `""` = clear, tối đa 256 ký tự |
| `actionName` | string | Override action name. `""` = clear, tối đa 128 ký tự |

```bash
curl -X PUT http://localhost:5001/api/v1/settings/captcha \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "siteKey": "NEW_SITE_KEY", "actionName": "" }'
```

---

### `GET /api/v1/settings/concurrency`

Config số luồng song song trên mỗi profile và stagger delay.

**Response 200** — `ConcurrencySettingsResponse`:
```json
{
  "value": 2,
  "max": 8,
  "delayMinSeconds": 5,
  "delayMaxSeconds": 15,
  "delayMaxAllowed": 300,
  "globalValue": 6,
  "globalMax": 16
}
```

| Field | Mô tả |
|---|---|
| `value` | Số pipeline 1 profile được chạy đồng thời (1 = tuần tự) |
| `max` | Trần cứng cho `value` |
| `delayMinSeconds` / `delayMaxSeconds` | Mỗi luồng chờ ngẫu nhiên trong khoảng này (giây) trước khi gọi |
| `delayMaxAllowed` | Trần cứng cho delay input |
| `globalValue` | Tổng số pipeline được chạy đồng thời trên TOÀN API (image + video, submit + poll). `value` mỗi profile bị giới hạn bởi trần này |
| `globalMax` | Trần cứng cho `globalValue` |

```bash
curl http://localhost:5001/api/v1/settings/concurrency -H "X-API-Key: $FASTAFF_KEY"
```

---

### `PUT /api/v1/settings/concurrency`

Cập nhật config. Có hiệu lực ngay, được lưu bền.

**Request body** — `UpdateConcurrencySettingsRequest`:

| Field | Type | Mô tả |
|---|---|---|
| `value` | int | Số luồng / profile, 1-8 |
| `delayMinSeconds` | int | Cận dưới delay, 0-300 |
| `delayMaxSeconds` | int | Cận trên delay, 0-300 |
| `globalValue` | int? | (Tuỳ chọn) Tổng pipeline đồng thời toàn API, 1-16. Bỏ qua / `null` = giữ nguyên |

Chuẩn hoá server-side: nếu `value = 1` thì delay bị ép về `0`; delay
chỉ có ý nghĩa khi chạy >1 luồng. Response giống `GET`.

```bash
curl -X PUT http://localhost:5001/api/v1/settings/concurrency \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": 2, "delayMinSeconds": 5, "delayMaxSeconds": 15, "globalValue": 6 }'
```

---

### Prices (billing price table)

Bảng giá per-job (image/video/r2v) dùng để auto-debit wallet khi job succeeded. Đơn vị: 1 credit = 1 VND. GET cho mọi authenticated key (UI Settings cần hiển thị); PUT là **Admin only**.

> Lưu ý: response/request DTO chỉ có `image`, `video`, `r2v`. Phase 47 f2v dùng chung price `video` (chưa tách field riêng).

---

### `GET /api/v1/settings/prices`

Trả về bảng giá per-gen-kind hiện tại. Per-job ledger hold/debit đọc bảng này tại thời điểm job INSERT.

**Response 200** — `PricesResponse`:
```json
{
  "image": 500,
  "video": 5000,
  "r2v": 6000
}
```

#### curl
```bash
curl http://localhost:5001/api/v1/settings/prices \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `PUT /api/v1/settings/prices`

Cập nhật một hoặc nhiều giá. Field bỏ qua (null) → giữ nguyên. Range mỗi field: `0..1_000_000`. **Admin only**.

**Request body** — `UpdatePricesRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `image` | `long?` |  | Giá 1 image job (VND). Range 0–1.000.000. Null = không đổi. |
| `video` | `long?` |  | Giá 1 video (Veo3 T2V) job. Cũng áp dụng cho f2v cho tới khi tách field riêng. |
| `r2v` | `long?` |  | Giá 1 R2V (reference-to-video) job. |

**Response 200** — `PricesResponse` (giá trị sau khi update, full snapshot).

**Response 403** — caller không phải admin:
```json
{ "error": "Admin API key required." }
```

**Status codes:** `200 OK` | `400 Bad Request` (range violation) | `403 Forbidden`

#### curl
```bash
curl -X PUT http://localhost:5001/api/v1/settings/prices \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"image": 500, "video": 5000, "r2v": 6000}'
```

#### JavaScript
```js
await fetch('http://localhost:5001/api/v1/settings/prices', {
  method: 'PUT',
  headers: {
    'X-API-Key': process.env.FASTAFF_ADMIN_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ image: 500, video: 5000, r2v: 6000 }),
});
```

#### Python
```python
import requests
requests.put(
    "http://localhost:5001/api/v1/settings/prices",
    headers={"X-API-Key": admin_key},
    json={"image": 500, "video": 5000, "r2v": 6000},
).raise_for_status()
```

---

### Pool Auto-Destroy (Phase 44)

Phase 44: tự động `DELETE /api/v1/profile/{id}` (wipe Chrome state, KEEPS cookies via re-injection) khi 1 profile gặp N captcha 403 PERMISSION_DENIED liên tiếp từ Google Labs. Mặc định OFF — bật ở Pool tab. Counters in-memory only — reset khi process restart hoặc gọi reset-counters.

---

### `GET /api/v1/settings/pool-auto-destroy`

Đọc policy hiện tại + live counters.

**Response 200** — `PoolAutoDestroyResponse`:
```json
{
  "enabled": true,
  "threshold": 5,
  "scopes": ["image", "video", "r2v"],
  "counters": {
    "abc123def456": 2,
    "9f8e7d6c5b4a": 0
  }
}
```

| Field | Type | Mô tả |
|---|---|---|
| `enabled` | `bool` | Policy có đang active không. |
| `threshold` | `int` | Số 403 liên tiếp trước khi destroy. Range 1–50. |
| `scopes` | `string[]` | Subset của `["image","video","r2v"]` — gen kind nào count vào counter. |
| `counters` | `map<profileId,int>` | Live consecutive-403 count per profile (in-memory). |

#### curl
```bash
curl http://localhost:5001/api/v1/settings/pool-auto-destroy \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `PUT /api/v1/settings/pool-auto-destroy`

Cập nhật policy. **Admin only**. Field null → giữ nguyên. Scopes invalid bị filter (whitelist `image`/`video`/`r2v`).

**Request body** — `UpdatePoolAutoDestroyRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `enabled` | `bool?` |  | Bật/tắt policy. Null = không đổi. |
| `threshold` | `int?` |  | Range 1–50. |
| `scopes` | `string[]?` |  | Null = không đổi, `[]` = không scope nào. Item ngoài whitelist bị bỏ. |

**Response 200** — `PoolAutoDestroyResponse` sau update.

**Response 403** — non-admin:
```json
{ "error": "Admin API key required." }
```

**Status codes:** `200 OK` | `400 Bad Request` | `403 Forbidden`

#### curl
```bash
curl -X PUT http://localhost:5001/api/v1/settings/pool-auto-destroy \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "threshold": 5, "scopes": ["image","video"]}'
```

#### JavaScript
```js
await fetch('http://localhost:5001/api/v1/settings/pool-auto-destroy', {
  method: 'PUT',
  headers: {
    'X-API-Key': process.env.FASTAFF_ADMIN_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ enabled: true, threshold: 5, scopes: ['image', 'video'] }),
});
```

---

### `POST /api/v1/settings/pool-auto-destroy/reset-counters`

Xoá toàn bộ in-memory consecutive-403 counters (về 0 cho mọi profile). Không ảnh hưởng history. **Admin only**.

**Response 200** — `PoolAutoDestroyResponse` (counters rỗng):
```json
{
  "enabled": true,
  "threshold": 5,
  "scopes": ["image", "video", "r2v"],
  "counters": {}
}
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -X POST http://localhost:5001/api/v1/settings/pool-auto-destroy/reset-counters \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

---

### `GET /api/v1/settings/pool-auto-destroy/history`

List recent auto-destroy events. Profile name/email được join từ profile lookup; nếu profile đã bị xoá thật thì 2 field này là `null`.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `limit` | `int` |  | Số rows tối đa. ≤0 hoặc bỏ trống → default 50. |

**Response 200** — `PoolAutoDestroyHistoryItem[]`:
```json
[
  {
    "id": 142,
    "profileId": "abc123def456",
    "profileName": "acc-01",
    "profileEmail": "x@gmail.com",
    "scope": "image",
    "threshold": 5,
    "counterN": 5,
    "triggeredAt": "2026-06-02T08:14:33Z",
    "destroyOk": true
  },
  {
    "id": 141,
    "profileId": "9f8e7d6c5b4a",
    "profileName": null,
    "profileEmail": null,
    "scope": "video",
    "threshold": 5,
    "counterN": 5,
    "triggeredAt": "2026-06-02T07:02:11Z",
    "destroyOk": false
  }
]
```

#### curl
```bash
curl "http://localhost:5001/api/v1/settings/pool-auto-destroy/history?limit=20" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `DELETE /api/v1/settings/pool-auto-destroy/history`

Xoá toàn bộ history rows. Không reset counters (xài endpoint riêng). **Admin only**.

**Response 200**:
```json
{ "ok": true, "deleted": 142 }
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -X DELETE http://localhost:5001/api/v1/settings/pool-auto-destroy/history \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

---

### `GET /api/v1/settings/pool-auto-destroy/stats`

Aggregate counts trên history table theo window thời gian. Dùng cho dashboard tab Pool.

**Response 200** — `PoolAutoDestroyStatsResponse`:
```json
{
  "last24h": 7,
  "last7d": 23,
  "last30d": 91,
  "allTime": 142
}
```

| Field | Type | Mô tả |
|---|---|---|
| `last24h` | `int` | Số events trong 24h gần nhất. |
| `last7d` | `int` | Số events trong 7d gần nhất. |
| `last30d` | `int` | Số events trong 30d gần nhất. |
| `allTime` | `int` | Tổng all-time. |

#### curl
```bash
curl http://localhost:5001/api/v1/settings/pool-auto-destroy/stats \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/settings/fp-toggle`

Trạng thái global fingerprint pipeline ON/OFF. **Anonymous** — captcha server colocated dùng endpoint này để biết có cần apply spoofing không.

**Mặc định kể từ 2026-06-06**: `enabled = false` (Linux native — coherent, ít 403). Bật `true` để dùng per-profile canvas + audio noise variation.

**Response 200** — `FpToggleSettingsResponse`:
```json
{ "enabled": false }
```

#### curl
```bash
curl http://localhost:5001/api/v1/settings/fp-toggle
# Không cần API key
```

---

### `PUT /api/v1/settings/fp-toggle`

Bật/tắt fingerprint pipeline toàn cục. Không cần permission đặc biệt (operational toggle giống `human-delay`).

**Request body** — `UpdateFpToggleRequest`:

| Field | Type | Mô tả |
|---|---|---|
| `enabled` | `bool` | `true` = ON (canvas/audio noise per-profile), `false` = OFF (Linux native) |

**Hiệu lực**:
- Backend: đọc ngay khi gen kế tiếp.
- Captcha server: tự fetch lại sau cache TTL **30s** — toggle KHÔNG instant trên solver.

**Response 200** — same `FpToggleSettingsResponse`.

#### curl
```bash
curl -X PUT http://localhost:5001/api/v1/settings/fp-toggle \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

> **Bối cảnh kỹ thuật**: trước 2026-06-06 fp pipeline cố giả Windows trên engine Linux → reCAPTCHA Enterprise phát hiện mismatch → 403 cao. Option A đã chuyển sang Linux-coherent (Mesa/SwiftShader). Option C đã bỏ CDP override + tất cả `defineProperty(navigator/screen, ...)` vì các pattern này lộ JS body (`getter.toString()`). Còn lại: chỉ canvas LSB noise + audio FFT noise per-profile. Khi `enabled=false`, captcha bỏ qua cả 2 noise đó.

---

### `GET /api/v1/settings/ua-mode` (2026-06-08)

Trạng thái Hybrid UA Mode — chế độ User-Agent dùng cho cả captcha solve (Chrome `--user-agent` flag) và backend submit (.NET FpHeaderApplier). **Anonymous** — captcha server colocated fetch endpoint này mỗi 30s để biết spoof UA gì khi spawn Chrome.

**Response 200** — `UaModeSettingsResponse`:
```json
{
  "mode": "auto",
  "presetId": null,
  "customUa": null,
  "presets": [
    {
      "id": "linux_chrome_148",
      "label": "Linux Chrome 148 (native — matches VPS binary)",
      "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
      "platform": "\"Linux\""
    },
    {
      "id": "windows_chrome_148",
      "label": "Windows Chrome 148 (spoof — majority of real users)",
      "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
      "platform": "\"Windows\""
    },
    {
      "id": "macos_chrome_148",
      "label": "macOS Chrome 148 (spoof — experimental)",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
      "platform": "\"macOS\""
    }
  ],
  "effectiveUa": null,
  "effectivePlatform": null
}
```

**Field nghĩa:**

| Field | Mô tả |
|---|---|
| `mode` | `"auto"` (default — per-fp Windows spoof) / `"preset"` / `"custom"` |
| `presetId` | non-null khi `mode="preset"`; phải khớp 1 entry trong `presets[]` |
| `customUa` | non-null khi `mode="custom"`; UA string đầy đủ |
| `presets[]` | danh sách preset hiện được hỗ trợ (curated, đã verify coherent với Chrome 148 binary) |
| `effectiveUa` | UA backend sẽ emit RIGHT NOW (preview cho operator). `null` khi `mode="auto"` (defer to per-fp logic) |
| `effectivePlatform` | sec-ch-ua-platform tương ứng |

#### curl
```bash
curl http://localhost:5001/api/v1/settings/ua-mode
# Không cần API key
```

---

### `PUT /api/v1/settings/ua-mode` (2026-06-08)

Set UA Mode. Hybrid: Auto / Preset / Custom.

**Request body** — `UpdateUaModeRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `mode` | `string` | ✓ | `"auto"` \| `"preset"` \| `"custom"` |
| `presetId` | `string?` | khi `mode="preset"` | Phải match `id` của 1 preset từ GET |
| `customUa` | `string?` | khi `mode="custom"` | UA string đầy đủ. Validate Chrome 148.x major |

**Validation 400** khi:
- `mode` không hợp lệ → `"Invalid UA mode 'X'. Allowed: auto\|preset\|custom."`
- `presetId` unknown → `"Unknown presetId 'X'."`
- `customUa` không đúng Chrome 148 → `"Custom UA must look like a Chrome 148.x UA — e.g. ..."`

**Hiệu lực:**
- Backend: đọc ngay lập tức qua `IUaSettingService.ApplyToContextAsync()` trong job pipeline → emit UA mới ở request kế tiếp.
- Captcha server: tự fetch sau cache TTL **30s** → apply lên Chrome spawn kế tiếp.

> ⚠️ **CAVEAT Pool mode**: Pool Chrome đã spawn trước khi anh đổi UA Mode sẽ vẫn dùng UA cũ tới khi RESPAWN (idle 30min hoặc recycle theo `solve_count`). Fresh mode KHÔNG có vấn đề này (spawn mới mỗi request).

#### curl
```bash
# Đổi sang Preset Linux native
curl -X PUT http://localhost:5001/api/v1/settings/ua-mode \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "preset", "presetId": "linux_chrome_148" }'

# Quay về Auto (per-fp Windows spoof)
curl -X PUT http://localhost:5001/api/v1/settings/ua-mode \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "mode": "auto" }'

# Custom UA (Advanced — phải Chrome 148.x)
curl -X PUT http://localhost:5001/api/v1/settings/ua-mode \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "custom",
    "customUa": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
  }'
```

> **Bối cảnh kỹ thuật**: trước Hybrid UA Mode, UA strictly per-fp via `fp.chrome_full_version` (Windows spoof default). Hybrid cho operator option override toàn cục để A/B test "Linux native coherence" vs "Windows demographic majority" mà không cần đụng code/restart. Trade-off mỗi mode: Auto = per-profile diversity (best); Preset Linux = 100% UA↔Client-Hints coherence; Preset Windows = strictly worse than Auto (mất diversity); Custom = power user A/B.

---

## In-Page Submit (Option A)

**Architecture summary** (2026-06-08).

```
LEGACY (split TLS):
  Chrome (captcha server) ── solve ──► token
                                          │
                                          ▼
  .NET HttpClient ── submit + token ──► aisandbox-pa
  
  → Token emitted from Chrome TLS+H2+UA, submitted from .NET TLS+H2+UA
  → Google sees fingerprint divergence → trust score decays
  → after 30-40 gen: 429 RESOURCE_EXHAUSTED or HttpIOException(ResponseEnded)

IN-PAGE (Option A):
  Chrome (captcha server) ── solve ──► token ── execute_async_script ──► fetch() ──► aisandbox-pa
                                                       (SAME Chrome session)
  
  → TLS + H2 + UA + cookies + Origin/Referer all from same Chrome
  → 0 fingerprint divergence
  → Google JSON errors visible directly (quota / score reject / etc.) — no more silent RST
```

### Flow ở backend
1. `ImageJobService` / `VideoJobService` / `R2VJobService` / `F2VJobService` đọc per-flow flag `UseInPageSubmit`.
2. Khi flag ON → branch sang `GenerateImageInPageAsync` / `SubmitInPageAsync` của client tương ứng (skip standalone `_captcha.GetTokenAsync`).
3. Client build body với placeholder `"__TOKEN__"` thay cho captcha token, gọi `ICaptchaSolveAndSubmit.SolveAndSubmitT2IAsync` (rename ngầm: dùng cho cả 4 flow + I2I).
4. CaptchaClient POST `/solve-and-submit-t2i` (alias `/solve-and-submit`) trên captcha server với payload đầy đủ (profileId, captchaMode, siteKey, action, cookieJson, submit{url, method, headers, bodyTemplate}).
5. Captcha server branch theo `captchaMode`:
   - `"profile"` (Pool): reuse warm Chrome instance từ pool (display range `:30-:49`)
   - `"fresh"` (Fresh): spawn ephemeral Chrome (display range `:10-:19`), cleanup sau request
6. Captcha server solve token + str.replace `__TOKEN__` → body cuối → `execute_async_script` với `fetch(url, {credentials: 'omit'})` → return response JSON về backend.
7. Backend parse response shape (image: `fife` URL hoặc inline base64; video: `mediaId` workflow handle).

### Per-flow flags (file-based, no UI yet)

| Flag | File path | Default | Hiện trạng |
|---|---|---|---|
| `GoogleLabs.UseInPageSubmit` | `appsettings.json` | `false` | ✅ Bật trên cả 2 VPS (T2I + I2I) |
| `Veo.UseInPageSubmit` | `appsettings.json` | `false` | OFF (chưa test gen) |
| `R2V.UseInPageSubmit` | `appsettings.json` | `false` | OFF (chưa test gen) |
| `F2V.UseInPageSubmit` | `appsettings.json` | `false` | OFF (chưa test gen) |

**Cách bật flag (SSH + edit JSON + systemctl restart):**
```bash
ssh root@VPS_IP
python3 -c "
import json
p = '/opt/fastaffapi/appsettings.json'
d = json.load(open(p))
d.setdefault('Veo', {})['UseInPageSubmit'] = True
json.dump(d, open(p,'w'), indent=2)
"
systemctl restart fastaffapi
```

### Captcha server endpoint

#### `POST http://captcha.example.com:5000/solve-and-submit-t2i` (alias: `/solve-and-submit`)

Combined captcha solve + in-page submit. Captcha server hold the same warm/ephemeral Chrome cho cả 2 bước.

**Request body**:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `profileId` | string | ✓ | Profile ID để load per-profile fp + cookie |
| `captchaMode` | string | ✓ | `"profile"` (Pool) hoặc `"fresh"` (ephemeral) |
| `captchaSiteKey` | string | ✓ | reCAPTCHA site key (lấy từ Captcha settings) |
| `captchaAction` | string | ✓ | `"IMAGE_GENERATION"` hoặc `"VIDEO_GENERATION"` |
| `captchaWebsiteUrl` | string | ✓ | URL labs.google flow page |
| `cookieJson` | string? | optional | Per-profile cookies JSON (decrypt từ profile.CookieEnc) |
| `proxy` | string? | optional | Proxy upstream cho Chrome |
| `submit` | object | ✓ | Submit info (xem dưới) |

`submit` object:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `url` | string | ✓ | URL aisandbox-pa endpoint |
| `method` | string | optional | Default `"POST"` |
| `headers` | object | ✓ | Map header (minimal: chỉ `accept` + `authorization`) |
| `bodyTemplate` | string | ✓ | Body JSON đầy đủ với literal `"__TOKEN__"` ở vị trí captcha token |
| `timeoutSec` | int | optional | Default `30` |

**Response 200**:
```json
{
  "status": "ok",
  "solveTimeS": 8.4,
  "submitTimeS": 1.2,
  "submitHttpStatus": 200,
  "submitResponseBody": "{...Google JSON response...}",
  "fp_id_used": "w10-gtx1650-8c-16g-1920",
  "solveCount": 3,
  "tokenLen": 2382
}
```

**Validation 400** khi thiếu field bắt buộc hoặc `bodyTemplate` không chứa `__TOKEN__`.

**5xx** khi captcha fail hoặc CDP `execute_async_script` exception.

---

### `GET /api/v1/settings/inpage-submit` (2026-06-08 v2)

Master toggle cho cơ chế **In-Page Submit** (Option A) — quyết định cả 5 luồng gen (T2I/I2I/Veo/R2V/F2V) có dùng captcha + Google submit chung 1 Chrome session hay submit qua .NET HttpClient (legacy).

**Mặc định**: `enabled = true` (bật cả 5 luồng). Hot-reload — không cần restart backend.

**Response 200** — `InPageSubmitSettingsResponse`:
```json
{ "enabled": true }
```

```bash
curl http://localhost:5001/api/v1/settings/inpage-submit \
  -H "X-API-Key: $FASTAFF_KEY"
```

### `PUT /api/v1/settings/inpage-submit`

Toggle ON/OFF.

**Request body** — `UpdateInPageSubmitRequest`:
```json
{ "enabled": false }
```

**Response 200**: cùng shape như GET. Lưu vào `app_settings` table key `inpage.enabled`.

```bash
curl -X PUT http://localhost:5001/api/v1/settings/inpage-submit \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
```

---

### `GET /api/v1/settings/captcha-concurrent`

Backend backpressure limiter — số captcha solve concurrent tối đa backend cho phép dispatch đến captcha server. Khi đạt cap, request mới chờ trong queue tại backend (KHÔNG đẩy đến captcha server → tránh "No display slot available" 503).

**Response 200** — `CaptchaConcurrencyResponse`:
```json
{
  "maxConcurrent": 8,
  "absoluteMax": 50,
  "default": 8,
  "availablePermits": 3,
  "currentlyHeld": 5,
  "totalAcquires": 12847,
  "totalWaitMs": 543210
}
```

Các counter (`currentlyHeld`, `totalAcquires`, `totalWaitMs`) là live state — refresh mỗi GET.

### `PUT /api/v1/settings/captcha-concurrent`

Đổi `maxConcurrent`. Hot-reload — semaphore tự resize.

**Request body** — `UpdateCaptchaConcurrencyRequest`:
```json
{ "maxConcurrent": 12 }
```

| Field | Validate | Note |
|---|---|---|
| `maxConcurrent` | `[Range(1, 50)]` | Không đổi `absoluteMax = 50` |

**Response 200**: cùng shape như GET.

---

### `GET /api/v1/settings/human-delay`

Delay ngẫu nhiên áp dụng giữa **captcha solve** và **Google submit** — mỗi request roll 1 giá trị ngẫu nhiên trong `[MinMs, MaxMs]`. Mục đích: mô phỏng user nhấp chuột pause trước khi submit, khử signature `<1ms back-to-back` mà reCAPTCHA Enterprise's risk-score model bắt.

> **2026-06-08 v2 — Semantic placement**: Pause này CHẠY TRONG CAPTCHA SERVER (giữa solve và fetch trong cùng Chrome session), KHÔNG phải `await Task.Delay()` ở backend trước khi gọi captcha. Đây là đúng "spirit" anti-detection.

**Response 200** — `HumanDelaySettingsResponse`:
```json
{
  "minMs": 3000,
  "maxMs": 10000,
  "maxAllowedMs": 60000
}
```

Set `minMs = maxMs = 0` để **tắt** delay hoàn toàn (submit ngay sau solve).

### `PUT /api/v1/settings/human-delay`

**Request body** — `UpdateHumanDelayRequest`:
```json
{ "minMs": 5000, "maxMs": 15000 }
```

| Field | Validate | Note |
|---|---|---|
| `minMs` | `[Range(0, 60000)]` | 0 = no min |
| `maxMs` | `[Range(0, 60000)]` | Tự clamp `>= minMs` |

**Response 200**: cùng shape như GET. Persist vào `app_settings`.

```bash
curl -X PUT http://localhost:5001/api/v1/settings/human-delay \
  -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "minMs": 3000, "maxMs": 10000 }'
```

---

## Bearer Pool Sync

Tự động đồng bộ bearer token từ MySQL "bearer pool" database vào
profiles. Chỉ admin mới dùng được.

### `GET /api/v1/settings/bearer-sync`

Lấy config hiện tại. Password **không** trả về (chỉ có flag `hasPassword`).

**Response 200** — `BearerSyncConfig`:
```json
{
  "enabled": true,
  "host": "db.example.com",
  "port": 3306,
  "database": "mydb",
  "username": "reader",
  "password": null,
  "hasPassword": true,
  "useSsl": false,
  "tableName": "bearer_pool",
  "colProfileId": "profile_id",
  "colBearerToken": "bearer_token",
  "colUpdatedAt": "updated_at",
  "colCookieJson": "cookie_json",
  "intervalMinutes": 30,
  "lastSyncAt": "2026-05-21T09:30:00Z",
  "lastSyncedCount": 5,
  "lastSyncStatus": "ok"
}
```

```bash
curl http://localhost:5001/api/v1/settings/bearer-sync \
  -H "X-API-Key: $ADMIN_KEY"
```

---

### `PUT /api/v1/settings/bearer-sync`

Lưu config. Password gửi plaintext trên wire, được mã hoá trước khi lưu.

**Request body** — `BearerSyncConfig` (các field read-only như `lastSyncAt`
bị bỏ qua):

| Field | Type | Mô tả |
|---|---|---|
| `enabled` | bool | Bật/tắt background sync worker |
| `host` | string | MySQL host, tối đa 255 ký tự |
| `port` | int | MySQL port, 1-65535, default 3306 |
| `database` | string | Tên database, tối đa 64 ký tự |
| `username` | string | MySQL user, tối đa 64 ký tự |
| `password` | string | Mật khẩu plaintext (chỉ truyền khi thay đổi) |
| `useSsl` | bool | Bật SSL/TLS kết nối MySQL |
| `tableName` | string | Tên bảng bearer pool, default `bearer_pool` |
| `colProfileId` | string | Tên cột profile ID, default `profile_id` |
| `colBearerToken` | string | Tên cột bearer token, default `bearer_token` |
| `colUpdatedAt` | string | Tên cột updated_at, default `updated_at` |
| `colCookieJson` | string | (Tuỳ chọn) Tên cột chứa cookie Google per-profile (JSON array), default `cookie_json`. Để trống/blank = bỏ qua, worker chỉ sync bearer (tương thích ngược với pool cũ không có cột này) |
| `intervalMinutes` | int | Chu kỳ sync (phút), 5-240, default 30 |

```bash
curl -X PUT http://localhost:5001/api/v1/settings/bearer-sync \
  -H "X-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "host": "db.example.com",
    "port": 3306,
    "database": "mydb",
    "username": "reader",
    "password": "secret",
    "tableName": "bearer_pool",
    "colProfileId": "profile_id",
    "colBearerToken": "bearer_token",
    "colUpdatedAt": "updated_at",
    "colCookieJson": "cookie_json",
    "intervalMinutes": 30
  }'
```

---

### `POST /api/v1/settings/bearer-sync/test`

Test kết nối MySQL với config hiện tại (hoặc config override gửi trong
body). Không làm thay đổi dữ liệu — chỉ đọc vài row để kiểm tra.

**Request body** (optional) — `BearerSyncConfig` hoặc `null`. Nếu gửi
body: password lấy từ body; nếu body không có password → lấy từ config
đã lưu.

**Response 200** — `BearerSyncTestResponse`:
```json
{
  "ok": true,
  "error": null,
  "rowCount": 12,
  "durationMs": 84,
  "sampleProfileIds": ["prof1", "prof2", "prof3"]
}
```

```bash
curl -X POST http://localhost:5001/api/v1/settings/bearer-sync/test \
  -H "X-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d 'null'
```

---

### `POST /api/v1/settings/bearer-sync/sync-now`

Trigger sync ngay lập tức (thay vì chờ đến chu kỳ tiếp theo). Chỉ hoạt
động khi config đã bật (`enabled: true`) và có host.

**Response 200** — `BearerSyncRunResponse`:
```json
{
  "ok": true,
  "error": null,
  "updated": 4,
  "skipped": 0,
  "durationMs": 213
}
```

**Response 400** khi sync chưa được cấu hình / bị tắt:
```json
{ "error": "Bearer sync is disabled or not configured." }
```

```bash
curl -X POST http://localhost:5001/api/v1/settings/bearer-sync/sync-now \
  -H "X-API-Key: $ADMIN_KEY"
```

---

## API Keys

Quản lý API key. **Yêu cầu admin key** (`isAdmin: true`).

### `GET /api/v1/admin/keys`

List toàn bộ API keys.

**Response 200** — `ApiKeyResponse[]`:
```json
[
  {
    "id": 1,
    "prefix": "test-k",
    "label": "dev-key",
    "isAdmin": true,
    "isActive": true,
    "maxConcurrent": 2,
    "createdAt": "2026-01-01T00:00:00Z",
    "lastUsedAt": "2026-05-21T10:00:00Z"
  }
]
```

```bash
curl http://localhost:5001/api/v1/admin/keys -H "X-API-Key: $ADMIN_KEY"
```

---

### `POST /api/v1/admin/keys`

Tạo API key mới. Raw key chỉ trả về **1 lần duy nhất** — lưu ngay.

**Request body** — `CreateApiKeyRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `label` | string | ✓ | Tên mô tả, 1-100 ký tự |
| `isAdmin` | bool | | Key có quyền admin không. Default `false` |
| `maxConcurrent` | int | | Giới hạn request đồng thời, 1-64, default 2 |

**Response 200** — `CreateApiKeyResponse`:
```json
{
  "key": "fastaff_abc123def456...",
  "info": {
    "id": 2,
    "prefix": "fastaf",
    "label": "client-key",
    "isAdmin": false,
    "isActive": true,
    "maxConcurrent": 2,
    "createdAt": "2026-05-21T10:00:00Z",
    "lastUsedAt": null
  }
}
```

```bash
curl -X POST http://localhost:5001/api/v1/admin/keys \
  -H "X-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "label": "client-key", "isAdmin": false, "maxConcurrent": 2 }'
```

---

### `PATCH /api/v1/admin/keys/{id}`

Cập nhật API key. Hiện tại chỉ hỗ trợ update `maxConcurrent`.

**Request body** — `UpdateApiKeyRequest`:

| Field | Type | Mô tả |
|---|---|---|
| `maxConcurrent` | int? | Giới hạn mới, 1-64. `null` = unchanged |

**Response 200** — `ApiKeyResponse`.

```bash
curl -X PATCH http://localhost:5001/api/v1/admin/keys/2 \
  -H "X-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "maxConcurrent": 5 }'
```

**Status codes:** `200 OK` | `404 Not Found`

---

### `DELETE /api/v1/admin/keys/{id}`

Thu hồi (deactivate) API key.

Bảo vệ:
- Không thể revoke key đang dùng để gọi.
- Không thể revoke admin key cuối cùng còn active.

**Response 204 No Content** khi thành công.

**Response 400**:
```json
{ "error": "Cannot revoke the key you are currently using." }
```

```bash
curl -X DELETE http://localhost:5001/api/v1/admin/keys/2 \
  -H "X-API-Key: $ADMIN_KEY"
```

**Status codes:** `204 No Content` | `400 Bad Request` | `404 Not Found`

---

## Roles & Permissions (RBAC)

> 🔐 **Phase 62 RBAC** — Hệ thống role + permission đầy đủ. **25 permissions** chia 4 system roles preset (Owner / Editor / Viewer / Captcha-Ops) + custom roles. Mỗi API key có thể attach nhiều role; permissions tính union từ tất cả roles attached.
>
> **Tất cả endpoints dưới đây** yêu cầu permission `admin.role.manage` (cho Roles) hoặc `admin.audit.read` (cho Audit log endpoint riêng).

### `GET /api/v1/admin/permissions`

Catalog đầy đủ các permission codes hệ thống.

**Response 200**:
```json
[
  {
    "id": 1,
    "code": "image.generate",
    "category": "generation",
    "description": "Tạo ảnh qua POST /api/v1/images",
    "isDangerous": false
  },
  {
    "id": 2,
    "code": "admin.role.manage",
    "category": "administration",
    "description": "Quản lý role + permission của API keys",
    "isDangerous": true
  }
]
```

`isDangerous = true` cho các permission cấp admin/destructive (xoá profile, role manage, wallet adjust, ...). UI hiển thị badge warning đỏ.

### `GET /api/v1/admin/roles`

List tất cả roles + permissions attached + count keys đang dùng.

**Response 200**:
```json
[
  {
    "id": 1,
    "name": "Owner",
    "description": "Full access (built-in)",
    "isSystem": true,
    "createdAt": "2026-06-03T12:00:00Z",
    "permissions": ["image.generate", "video.generate", "admin.role.manage", "..."],
    "assignedKeyCount": 2
  }
]
```

### `GET /api/v1/admin/roles/{id}`

**Response 200**: `RoleResponse` (như trên). **Errors**: 404 not found.

### `POST /api/v1/admin/roles`

Tạo role mới (custom). System role (Owner/Editor/Viewer/Captcha-Ops) tạo sẵn — không tạo trùng tên.

**Request body**:
```json
{
  "name": "VideoOnly",
  "description": "Chỉ được gen video, không sửa profile",
  "permissions": ["video.generate", "r2v.generate", "f2v.generate", "profile.read"]
}
```

| Field | Validate | Note |
|---|---|---|
| `name` | required, 1-64 | Unique |
| `description` | max 256 | Optional |
| `permissions` | string[] | List permission codes (xem `/admin/permissions`) |

**Response 200**: `RoleResponse`.

**Errors**: 400 `invalid_request` (vd unknown permission code), 409 `conflict` (name trùng).

**Side effect**: ghi audit log `admin.role.create`.

### `PATCH /api/v1/admin/roles/{id}`

Update role. `null = giữ nguyên`, `[] = wipe`. System role: name + description **cố định**, chỉ permissions edit được (nhưng cẩn thận — system role bằng-mặt-design).

**Request body** (mọi field optional):
```json
{
  "description": "Thêm quyền R2V",
  "permissions": ["video.generate", "r2v.generate", "f2v.generate", "profile.read"]
}
```

**Response 200**: `RoleResponse`. **Errors**: 404, 400 `invalid_request`, 409 `conflict`.

**Side effect**: audit `admin.role.update`. Cache permission per-key được invalidate cho mọi key gắn role này.

### `DELETE /api/v1/admin/roles/{id}`

Xoá role custom. System role bị blocked (409 `system_role`).

**Response 204** No Content. **Errors**: 404, 409 `system_role`.

**Side effect**: audit `admin.role.delete`.

### `GET /api/v1/admin/keys/{keyId}/roles`

Lấy danh sách roles đang attach vào 1 API key cụ thể.

**Response 200**:
```json
[
  { "id": 1, "name": "Owner", "isSystem": true },
  { "id": 5, "name": "VideoOnly", "isSystem": false }
]
```

### `PUT /api/v1/admin/keys/{keyId}/roles`

**Full replace** — gán lại tập role cho key. Truyền `[]` = bỏ hết role (key sẽ không có permission gì).

**Request body**:
```json
{ "roleIds": [1, 5] }
```

**Response 200**: list roles attached (như GET).

**Errors**: 400 `unknown_role` (nếu có roleId không tồn tại trong DB).

**Side effect**: invalidate per-key permission cache + audit `admin.key.assign_roles`.

```bash
# Gán role Owner (id=1) và VideoOnly (id=5) cho key id=42
curl -X PUT http://localhost:5001/api/v1/admin/keys/42/roles \
  -H "X-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "roleIds": [1, 5] }'
```

---

## Audit Log

> 📜 **Phase 62 RBAC** — Audit log ghi mọi action admin (role create/update/delete, key role assign, settings change quan trọng, ...). Bảng `audit_log` ghi append-only.
>
> Tất cả endpoint dưới yêu cầu permission `admin.audit.read`.

### `GET /api/v1/admin/audit`

Paginated read audit log với filter.

**Query params**:
- `limit` (int, default 100, clamped 1-500)
- `offset` (int, default 0, min 0)
- `action` (string, optional): filter exact match action name (vd `admin.role.create`)
- `actorId` (long, optional): filter theo `api_key.id` đã trigger action

**Response 200**:
```json
{
  "total": 247,
  "limit": 100,
  "offset": 0,
  "items": [
    {
      "id": 247,
      "ts": "2026-06-08T13:42:11Z",
      "actorId": 1,
      "actorLabel": "admin-key (ApiKey:1)",
      "action": "admin.role.update",
      "targetKind": "role",
      "targetId": "5",
      "ip": "203.0.113.45",
      "httpStatus": 200,
      "details": "{\"renamed\":\"VideoOnly→VideoFull\"}"
    }
  ]
}
```

**Errors**: 403 `missing_permission` (permission: `admin.audit.read`).

```bash
# Filter các action role.* của actor id=1, latest 50
curl "http://localhost:5001/api/v1/admin/audit?action=admin.role.update&actorId=1&limit=50" \
  -H "X-API-Key: $ADMIN_KEY"
```

---

## GoClaw AI Assistant

> 🤖 **Phase 64** — Integration với GoClaw upstream (OpenAI-compatible AI gateway). Backend dùng GoClaw cho 2 thứ:
> 1. **In-tool AI Assistant** (sidebar chat trong ProfileLogin tool)
> 2. **Auto-tagger** — gen tag cho job dùng AI (trickle worker background, đọc `prompt` của job → tag thuộc taxonomy)
>
> **Tất cả endpoints** yêu cầu permission `admin.settings.manage`.

### `GET /api/v1/goclaw/config`

Đọc config hiện tại. Bearer **không** trả về (chỉ flag `hasBearer`).

**Response 200** — `GoClawConfigResponse`:
```json
{
  "endpoint": "http://103.161.17.109:18790",
  "userId": "fastaffapi-prod",
  "agentModel": "claude-sonnet-4-5",
  "hasBearer": true,
  "taggerEnabled": true,
  "batchLazy": 5,
  "batchTrickle": 30,
  "intervalMinutes": 15
}
```

| Field | Meaning |
|---|---|
| `endpoint` | GoClaw OpenAI-compatible base URL |
| `userId` | Identifier gửi kèm trong messages (tracking) |
| `agentModel` | Model slug — xem `/goclaw/agents` để list |
| `hasBearer` | True nếu bearer đã set (raw value không expose) |
| `taggerEnabled` | Bật trickle worker tự gen tag |
| `batchLazy` | Số job tag per `/gallery/ensure-tag` call (default 5) |
| `batchTrickle` | Số job tag per worker tick (default 30) |
| `intervalMinutes` | Worker tick interval (default 15) |

### `PUT /api/v1/goclaw/config`

Update config. Field nào `null` = giữ nguyên. `bearer = ""` = **clear** bearer (disable integration).

**Request body** — `UpdateGoClawConfigRequest`:
```json
{
  "endpoint": "http://103.161.17.109:18790",
  "bearer": "sk-...",
  "userId": "fastaffapi-prod",
  "agentModel": "claude-sonnet-4-5",
  "taggerEnabled": true,
  "batchLazy": 5,
  "batchTrickle": 30,
  "intervalMinutes": 15
}
```

**Response 200**: cùng shape như GET.

### `GET /api/v1/goclaw/agents`

Proxy list agents từ GoClaw upstream.

**Response 200**:
```json
[
  { "slug": "claude-sonnet-4-5", "displayName": "Claude Sonnet 4.5", "provider": "anthropic", "model": "claude-sonnet-4-5-20251022" },
  { "slug": "gpt-4o-mini",       "displayName": "GPT-4o Mini",       "provider": "openai",    "model": "gpt-4o-mini-2024-07-18" }
]
```

### `POST /api/v1/goclaw/test`

Round-trip probe — gọi upstream với prompt "reply OK" để verify config hoạt động.

**Request body** — `GoClawTestRequest`:
```json
{
  "agentModel": "claude-sonnet-4-5",
  "endpoint": "http://103.161.17.109:18790",
  "bearer": "sk-...",
  "userId": "test-probe"
}
```

`endpoint` / `bearer` / `userId` **optional override** — nếu omit, dùng config hiện tại.

**Response 200** — `GoClawTestResponse`:
```json
{
  "ok": true,
  "latencyMs": 432,
  "reply": "OK",
  "totalTokens": 12,
  "error": null
}
```

**Returns 200 với `ok=false`** trên config_incomplete / upstream error (KHÔNG return 500):
```json
{ "ok": false, "latencyMs": 0, "reply": "", "totalTokens": 0, "error": "config_incomplete: bearer empty" }
```

### `GET /api/v1/goclaw/tagger/status`

Trạng thái + counters worker auto-tagger.

**Response 200** — `TaggerStatusResponse`:
```json
{
  "running": true,
  "enabled": true,
  "untaggedCount": 142,
  "lastRunAt": "2026-06-08T13:30:00Z",
  "lastRunBatchSize": 28,
  "lastError": null,
  "totalTagged24h": 487
}
```

`running` luôn `true` (worker thread sống). `enabled` = config flag toggle. `lastError` = exception message của lần chạy gần nhất nếu fail.

### `POST /api/v1/goclaw/tagger/run-now`

Trigger ngay 1 batch tag, KHÔNG chờ worker tick.

**Request body** (optional):
```json
{ "maxJobs": 50 }
```

`maxJobs` clamped (0, 500]. Default = `config.batchTrickle`.

**Response 200**:
```json
{ "ok": true, "tagged": 28, "max": 50 }
```

**Errors**: 400 `config_incomplete` nếu config GoClaw chưa setup.

### `POST /api/v1/goclaw/tagger/retag-all`

**Wipe ALL auto tags** — sau đó trickle worker sẽ tag lại từ đầu (cần nhiều giờ tùy số job). Body: không có.

**Response 200**:
```json
{ "ok": true, "wiped": 1247 }
```

**Use case**: đổi `agentModel` (vd Claude Sonnet → GPT-4o) → muốn tag lại toàn bộ với model mới.

```bash
curl -X POST http://localhost:5001/api/v1/goclaw/tagger/retag-all \
  -H "X-API-Key: $ADMIN_KEY"
```

---

## Wallets

Hệ thống wallet auto-debit khi job succeeded. Mỗi API key có 1 wallet — admin có thể top-up và adjust thủ công. Caller xem được wallet của chính mình qua `/me/wallet`.

Credit là số nguyên (`long`, đơn vị VND nội bộ). Unit cost mỗi job kind (`image` / `video` / `r2v` / `f2v`) lấy từ `GET /api/v1/settings/prices`. Khi gen request lên, wallet **hold** (debit `balance`, cộng vào `held`); job success → **capture** (giảm `held`); job fail/cancel → **release** (trả lại `balance`). Mọi chuyển động được audit vào bảng `wallet_ledger`. Admin key được mark `is_admin = 1` → billing-exempt (mọi gen call bypass wallet, không tạo ledger row).

Insufficient balance khi enqueue gen → endpoint trả `402 Payment Required` (xem mục Images/Videos/R2V/F2V), wallet không bị động đến.

---

### `GET /api/v1/admin/wallets/{keyId}`

_Admin only._ Snapshot wallet của bất kỳ API key nào theo numeric id.

**Path params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `keyId` | long | ✓ | Numeric id của API key (xem `GET /api/v1/admin/keys`). |

**Response 200** — `WalletSnapshotResponse`:
```json
{
  "apiKeyId": 17,
  "balance": 482000,
  "held": 18000,
  "total": 500000
}
```

`balance` = credit khả dụng. `held` = credit đang reserved cho gen đang chạy. `total` = `balance + held` (tổng credit chưa tiêu).

**Status codes:** `200 OK` | `403 Forbidden` (không phải admin key) | `404 Not Found` (keyId không tồn tại).

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  http://localhost:5001/api/v1/admin/wallets/17
```

---

### `GET /api/v1/admin/wallets/{keyId}/ledger`

_Admin only._ Liệt kê các ledger entry của 1 wallet, mới nhất trước (`ORDER BY id DESC`).

**Path params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `keyId` | long | ✓ | Numeric id của API key. |

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `limit` | int | | Số row trả về. Default `50`, hợp lệ `1..500`, ngoài range tự fallback về `50`. |
| `offset` | int | | Skip N row. Default `0`. |

**Response 200** — `LedgerEntryResponse[]`:
```json
[
  {
    "id": 9421,
    "type": "capture",
    "amount": -3000,
    "balanceAfter": 482000,
    "heldAfter": 15000,
    "jobKind": "image",
    "jobId": "ccb19602e59fdf7e851f948593bf1957",
    "reason": "gen_success",
    "createdAt": "2026-06-02T03:14:22.1830000Z"
  },
  {
    "id": 9420,
    "type": "hold",
    "amount": -3000,
    "balanceAfter": 482000,
    "heldAfter": 18000,
    "jobKind": "image",
    "jobId": null,
    "reason": "image_gen_hold",
    "createdAt": "2026-06-02T03:14:20.4421000Z"
  },
  {
    "id": 9388,
    "type": "topup",
    "amount": 500000,
    "balanceAfter": 500000,
    "heldAfter": 0,
    "jobKind": null,
    "jobId": null,
    "reason": "admin_topup",
    "createdAt": "2026-06-01T22:01:11.9920000Z"
  }
]
```

`type` ∈ `topup | hold | capture | release | adjust`. `amount` âm = trừ vào balance (`hold` / `capture` / `adjust` âm), dương = cộng vào (`topup` / `release` / `adjust` dương). `balanceAfter` + `heldAfter` là snapshot sau khi entry này commit. `jobKind` / `jobId` chỉ set với row sinh từ gen pipeline (`hold` không có `jobId` vì job chưa enqueue xong).

**Status codes:** `200 OK` | `403 Forbidden` | `404 Not Found`.

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/admin/wallets/17/ledger?limit=20&offset=0"
```

---

### `POST /api/v1/admin/wallets/{keyId}/topup`

_Admin only._ Cộng credit vào wallet của 1 key. Tạo ledger row `type=topup`.

**Request body** — `TopupRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `amount` | long | ✓ | Số credit cộng vào, phải `>= 1` và `<= 1_000_000_000`. |
| `reason` | string | | Ghi chú free-text, max 200 ký tự. Bỏ trống → backend tự set `"admin_topup"`. |

**Response 200** — `WalletSnapshotResponse` (snapshot sau topup):
```json
{
  "apiKeyId": 17,
  "balance": 982000,
  "held": 18000,
  "total": 1000000
}
```

**Status codes:** `200 OK` | `400 Bad Request` (amount ngoài range / validation fail) | `403 Forbidden` | `404 Not Found`.

#### curl
```bash
curl -X POST -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 500000, "reason": "monthly recharge"}' \
  http://localhost:5001/api/v1/admin/wallets/17/topup
```

#### JavaScript
```js
const res = await fetch("http://localhost:5001/api/v1/admin/wallets/17/topup", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.FASTAFF_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: 500000, reason: "monthly recharge" }),
});
const snap = await res.json();
```

#### Python
```python
import os, requests
r = requests.post(
    "http://localhost:5001/api/v1/admin/wallets/17/topup",
    headers={"X-API-Key": os.environ["FASTAFF_KEY"]},
    json={"amount": 500000, "reason": "monthly recharge"},
)
snap = r.json()
```

#### C#
```csharp
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5001/") };
http.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("FASTAFF_KEY"));
var res  = await http.PostAsJsonAsync("api/v1/admin/wallets/17/topup",
    new { amount = 500000L, reason = "monthly recharge" });
var snap = await res.Content.ReadFromJsonAsync<WalletSnapshotResponse>();
```

---

### `POST /api/v1/admin/wallets/{keyId}/adjust`

_Admin only._ Điều chỉnh balance thủ công (debit hoặc credit). Dùng để clawback topup sai, refund bù credit, hoặc bù trừ kế toán. Tạo ledger row `type=adjust`.

**Request body** — `AdjustRequest`:

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `amount` | long | ✓ | **Signed**. Dương = cộng vào balance, âm = trừ. Range `-1_000_000_000..1_000_000_000`. `0` → no-op trả snapshot hiện tại. |
| `reason` | string | | Lý do, max 200 ký tự. Bỏ trống → backend set `"admin_adjust"`. |

Lưu ý: backend KHÔNG chặn balance đi dưới 0 — admin chịu trách nhiệm với clawback vượt số dư.

**Response 200** — `WalletSnapshotResponse`:
```json
{
  "apiKeyId": 17,
  "balance": 932000,
  "held": 18000,
  "total": 950000
}
```

**Status codes:** `200 OK` | `400 Bad Request` | `403 Forbidden` | `404 Not Found`.

#### curl
```bash
curl -X POST -H "X-API-Key: $FASTAFF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": -50000, "reason": "clawback duplicate topup"}' \
  http://localhost:5001/api/v1/admin/wallets/17/adjust
```

#### JavaScript
```js
await fetch("http://localhost:5001/api/v1/admin/wallets/17/adjust", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.FASTAFF_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: -50000, reason: "clawback duplicate topup" }),
});
```

#### Python
```python
requests.post(
    "http://localhost:5001/api/v1/admin/wallets/17/adjust",
    headers={"X-API-Key": os.environ["FASTAFF_KEY"]},
    json={"amount": -50000, "reason": "clawback duplicate topup"},
)
```

---

### `GET /api/v1/me/wallet`

Snapshot wallet của chính caller (key được dùng để authenticate request này). Không cần admin. Admin key gọi sẽ thấy chính row của mình, dù admin gens là billing-exempt nên balance/held thường đứng yên.

**Response 200** — `WalletSnapshotResponse`:
```json
{
  "apiKeyId": 42,
  "balance": 127000,
  "held": 6000,
  "total": 133000
}
```

**Status codes:** `200 OK` | `401 Unauthorized` (thiếu / sai `X-API-Key`).

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  http://localhost:5001/api/v1/me/wallet
```

---

### `GET /api/v1/me/wallet/ledger`

Paginated ledger của chính caller. Cùng shape & cùng query params với variant admin.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `limit` | int | | Default `50`, hợp lệ `1..500`. |
| `offset` | int | | Default `0`. |

**Response 200** — `LedgerEntryResponse[]`:
```json
[
  {
    "id": 9512,
    "type": "capture",
    "amount": -6000,
    "balanceAfter": 127000,
    "heldAfter": 0,
    "jobKind": "video",
    "jobId": "f2b21d9c-e0a8-4f55-9e1a-3441cb0719a2",
    "reason": "gen_success",
    "createdAt": "2026-06-02T07:48:09.3120000Z"
  },
  {
    "id": 9511,
    "type": "hold",
    "amount": -6000,
    "balanceAfter": 127000,
    "heldAfter": 6000,
    "jobKind": "video",
    "jobId": null,
    "reason": "video_gen_hold",
    "createdAt": "2026-06-02T07:47:42.1004000Z"
  }
]
```

**Status codes:** `200 OK` | `401 Unauthorized`.

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/me/wallet/ledger?limit=50"
```

---

## Captcha Pool

**Admin-only.** Quản lý warm Chrome instances trên captcha server khi profile
chạy `captchaMode: "profile"` (xem [Profiles](#profiles)). Đây là lớp proxy
mỏng — server này gọi sang captcha server và pass-through JSON. Dùng cho tab
"🚀 Pool" của UI để inspect/kill/destroy mà không cần SSH vào captcha VPS.

Mọi endpoint cần **admin API key** (`403 Forbidden` nếu không phải admin).
Khi lỗi `502 Bad Gateway`: `pool-status` / `cleanup-*` trả `reason: "captcha_down"`
(không kết nối được captcha server); còn `kill` / `destroy` trả
`reason: "captcha_error"` (gọi tới được nhưng thao tác thất bại).

> **Captcha solve contract (A1, 2026-06-06)**: response từ
> `/get-token-profile` của captcha server bao gồm thêm field `fp_id_used`
> (string | null) — fp id mà solver thực sự dùng tại thời điểm solve.
> Backend so với `job.FingerprintId` đã snapshot; nếu lệch → re-solve 1
> lần, lệch lần 2 → submit anyway + log `FP_MISMATCH`. Fresh mode KHÔNG
> echo (null) → backend skip check. Per-profile lock timeout đã tăng
> `60s → 150s` (2026-06-06) để giảm 503 "Profile busy" khi 2 request
> tới gần nhau.

### `GET /api/v1/captcha/pool-status`

Trả raw JSON status của pool từ captcha server (items, max size, idle kill
threshold...).

**Response 200** (pass-through từ captcha server):
```json
{
  "poolSize": 1,
  "maxPoolSize": 20,
  "idleKillAfterSec": 600,
  "cleanupIntervalSec": 60,
  "displayRange": ":30-:49",
  "usedDisplays": [30],
  "items": [
    { "profileId": "abc123def456", "solveCount": 3, "lastUsedAgoSec": 12.5, "display": 30 }
  ]
}
```

```bash
curl http://localhost:5001/api/v1/captcha/pool-status \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden` | `502 Bad Gateway`

---

### `POST /api/v1/captcha/profile/{profileId}/kill`

Shutdown Chrome driver của 1 profile nhưng GIỮ thư mục profile (cookie + cache
còn để respawn warm lần sau).

**Response 200**:
```json
{ "status": "ok", "profileId": "abc123def456", "action": "kill" }
```

**Status codes:** `200 OK` | `403 Forbidden` | `502 Bad Gateway`

---

### `DELETE /api/v1/captcha/profile/{profileId}`

Destroy toàn bộ: kill Chrome + xoá thư mục profile (cookie + cache bị wipe).

**Response 200**:
```json
{ "status": "ok", "profileId": "abc123def456", "action": "destroy" }
```

**Status codes:** `200 OK` | `403 Forbidden` | `502 Bad Gateway`

---

### `POST /api/v1/captcha/cleanup-orphans`

Destroy mọi pool entry có `profileId` KHÔNG còn tồn tại trong DB profile của
tool. Cùng logic với background sweeper nhưng trigger thủ công.

**Response 200**:
```json
{ "destroyed": 2, "orphanIds": ["oldid1", "oldid2"] }
```
Khi pool rỗng hoặc không có orphan → `destroyed: 0` kèm `note`.

```bash
curl -X POST http://localhost:5001/api/v1/captcha/cleanup-orphans \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden` | `502 Bad Gateway`

---

### `POST /api/v1/captcha/cleanup-idle`

Kill (shutdown driver, giữ dir) mọi pool entry idle lâu hơn `minutes`. Nhẹ hơn
destroy — dir còn nên solve kế tiếp respawn warm.

**Query:** `minutes` (mặc định 60, kẹp 1–1440).

**Response 200**:
```json
{ "killed": 1, "idleIds": ["abc123def456"], "thresholdMinutes": 60 }
```

```bash
curl -X POST "http://localhost:5001/api/v1/captcha/cleanup-idle?minutes=30" \
  -H "X-API-Key: $FASTAFF_ADMIN_KEY"
```

**Status codes:** `200 OK` | `403 Forbidden` | `502 Bad Gateway`

---

## Diagnostics

Tools để debug từng dependency riêng lẻ mà không tốn quota gen thật.

### `GET /api/v1/diagnostics/captcha`

Gọi captcha server với effective config hiện tại. Trả về kết quả thật
(token preview, duration, lỗi nếu có).

**Query params:**

| Param | Type | Mô tả |
|---|---|---|
| `proxy` | string | Optional — test với proxy cụ thể mà không cần lưu vào profile |

**Response 200** khi thành công:
```json
{
  "ok": true,
  "durationMs": 23410,
  "serverUrl": "http://captcha.example.com",
  "siteKey": "6Lds...",
  "action": "IMAGE_GENERATION",
  "websiteUrl": "https://labs.google/fx/vi/tools/flow",
  "proxy": null,
  "tokenLength": 420,
  "tokenPreview": "03AGdBq25xY7..."
}
```

**Response 200** khi lỗi:
```json
{
  "ok": false,
  "durationMs": 5000,
  "serverUrl": "http://captcha.example.com",
  "proxy": null,
  "errorType": "CaptchaServerException",
  "error": "Cannot reach captcha server",
  "hint": "Test from PowerShell: curl http://... — should return JSON."
}
```

```bash
curl http://localhost:5001/api/v1/diagnostics/captcha \
  -H "X-API-Key: $FASTAFF_KEY"

# Test với proxy
curl "http://localhost:5001/api/v1/diagnostics/captcha?proxy=user:pass@1.2.3.4:8080" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

### `GET /api/v1/diagnostics/shoplike`

Test ShopLike access_token (gọi `getCurrentProxy`, fallback sang
`getNewProxy` nếu chưa có IP nào được cấp). Dùng để verify key trước khi
save vào profile.

**Query params:**

| Param | Type | Required | Mô tả |
|---|---|---|---|
| `key` | string | ✓ | ShopLike access_token cần test |

**Response 200** khi thành công:
```json
{
  "ok": true,
  "durationMs": 1240,
  "proxy": "1.2.3.4:8080",
  "source": "current"
}
```

`source`: `"current"` (getCurrentProxy) hoặc `"new"` (getNewProxy, khi
key mới chưa có IP nào). Ở nhánh `"new"` body kèm thêm
`note: "Allocated first proxy via getNewProxy."`.

**Response 200** khi lỗi:
```json
{
  "ok": false,
  "durationMs": 520,
  "error": "Invalid token",
  "nextChangeSeconds": null
}
```

**Response 400** khi thiếu param `key`:
```json
{ "ok": false, "error": "Missing query string parameter 'key'." }
```

```bash
curl "http://localhost:5001/api/v1/diagnostics/shoplike?key=YOUR_SHOPLIKE_KEY" \
  -H "X-API-Key: $FASTAFF_KEY"
```

---

## Reports

Báo cáo aggregate cho admin dashboard — đếm số job succeeded theo ngày/profile, kèm CSV export. Mặc định múi giờ VN+7 (ICT). Mọi endpoint dưới đây đều **Admin only** — gọi bằng API key không có flag `IsAdmin` sẽ nhận `403 Forbidden` với `reason: "admin_required"`.

Phase 54 đổi semantic: counter quota tính theo **succeeded only** (không phải total requests) và reset tại VN+7 day boundary; mọi count trong các response dưới đây vì thế đều có cả `Succeeded` lẫn `Total` để UI render success-rate %.

---

### `GET /api/v1/reports/summary`

Tổng hợp counter image/video/r2v/f2v trong khoảng `[from, to)` UTC, kèm tách riêng I2I vs T2I (image jobs có `reference_images_json` được tính là I2I — Phase 49).

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `from` | ISO datetime (UTC) | ✓ | Mốc đầu khoảng, inclusive. `Unspecified` Kind sẽ được coi là UTC. |
| `to` | ISO datetime (UTC) | ✓ | Mốc cuối khoảng, exclusive. |

**Response 200** — `SummaryDto`:
```json
{
  "image": { "succeeded": 1842, "total": 2103 },
  "i2I":   { "succeeded": 421,  "total": 489 },
  "t2I":   { "succeeded": 1421, "total": 1614 },
  "video": { "succeeded": 312,  "total": 408 },
  "r2V":   { "succeeded": 87,   "total": 119 },
  "f2V":   { "succeeded": 24,   "total": 31 },
  "total": { "succeeded": 2265, "total": 2661 },
  "activeProfiles": 14,
  "totalProfiles": 22,
  "from": "2026-06-01T00:00:00Z",
  "to":   "2026-06-02T00:00:00Z"
}
```

**Response 403** — không phải admin key:
```json
{ "error": "Admin API key required.", "reason": "admin_required" }
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/reports/summary?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z"
```

---

### `GET /api/v1/reports/by-day`

Phân rã `summary` theo từng ngày ICT (VN+7). SQL group key là `substr(datetime(created_at,'+7 hours'),1,10)` nên một job tạo lúc 23:00 UTC sẽ rơi vào ngày ICT kế tiếp. `imageLimit`/`videoLimit` là `SUM(*_quota_per_day)` trên tất cả profile có `quota_enabled = 1` — UI dùng để render "succ / limit (pct%)" cho cả fleet.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `from` | ISO datetime (UTC) | ✓ | Mốc đầu khoảng. |
| `to` | ISO datetime (UTC) | ✓ | Mốc cuối khoảng, exclusive. |

**Response 200** — `List<DayBucketDto>` (sort theo `date` DESC):
```json
[
  {
    "date": "2026-06-02",
    "image": { "succeeded": 612, "total": 701 },
    "video": { "succeeded": 104, "total": 138 },
    "r2V":   { "succeeded": 31,  "total": 42  },
    "f2V":   { "succeeded": 9,   "total": 11  },
    "total": { "succeeded": 756, "total": 892 },
    "imageLimit": 4400,
    "videoLimit": 880
  },
  {
    "date": "2026-06-01",
    "image": { "succeeded": 1230, "total": 1402 },
    "video": { "succeeded": 208,  "total": 270  },
    "r2V":   { "succeeded": 56,   "total": 77   },
    "f2V":   { "succeeded": 15,   "total": 20   },
    "total": { "succeeded": 1509, "total": 1769 },
    "imageLimit": 4400,
    "videoLimit": 880
  }
]
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/reports/by-day?from=2026-05-26T00:00:00Z&to=2026-06-02T17:00:00Z"
```

---

### `GET /api/v1/reports/by-profile`

Pivot count theo `profile_id`, sort theo tổng `succeeded` DESC (profile delivery nhiều nhất lên đầu). JOIN `profiles` để lấy display name + per-profile daily quota (`null` nếu quota tắt). Profile đã xoá hiện `"(deleted)"`.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `from` | ISO datetime (UTC) | ✓ | Mốc đầu khoảng. |
| `to` | ISO datetime (UTC) | ✓ | Mốc cuối khoảng, exclusive. |
| `offset` | int | | Mặc định `0`, âm sẽ bị reset về `0`. |
| `limit` | int | | Mặc định `20`, clamp `[1, 200]`. |

**Response 200** — `PaginatedDto<ProfileBucketDto>`:
```json
{
  "items": [
    {
      "profileId": "p_8f3a92c14b",
      "profileName": "veo3-pool-01",
      "image": { "succeeded": 184, "total": 211 },
      "video": { "succeeded": 42,  "total": 58  },
      "r2V":   { "succeeded": 11,  "total": 16  },
      "f2V":   { "succeeded": 3,   "total": 4   },
      "total": { "succeeded": 240, "total": 289 },
      "imageQuotaPerDay": 200,
      "videoQuotaPerDay": 40
    },
    {
      "profileId": "p_2d71b40a9c",
      "profileName": "(deleted)",
      "image": { "succeeded": 88, "total": 102 },
      "video": { "succeeded": 19, "total": 26  },
      "r2V":   { "succeeded": 4,  "total": 7   },
      "f2V":   { "succeeded": 1,  "total": 1   },
      "total": { "succeeded": 112, "total": 136 },
      "imageQuotaPerDay": null,
      "videoQuotaPerDay": null
    }
  ],
  "total": 14,
  "offset": 0,
  "limit": 20
}
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/reports/by-profile?from=2026-06-01T00:00:00Z&to=2026-06-02T17:00:00Z&offset=0&limit=20"
```

---

### `GET /api/v1/reports/quota-usage`

Live counter quota cho **tất cả profile có `QuotaEnabled = 1`**. Window: per-hour và per-day, reset ở VN+7 ICT boundary (Phase 54). Video window cộng dồn `video_jobs + r2v_jobs + f2v_jobs` cùng một quota (mọi job sinh video đều trừ vào quota video). Image quota chỉ count `image_jobs`. Trường `image`/`video` là `null` nếu profile không bật quota cho kind đó.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `offset` | int | | Mặc định `0`. |
| `limit` | int | | Mặc định `20`, clamp `[1, 200]`. |
| `search` | string | | Phase 56 — filter server-side, case-insensitive trên `Name` HOẶC `Tags`. |

**Response 200** — `PaginatedDto<QuotaRowDto>`:
```json
{
  "items": [
    {
      "profileId": "p_8f3a92c14b",
      "profileName": "veo3-pool-01",
      "tags": ["prod", "veo3", "pool-a"],
      "image": {
        "perHour": { "limit": 20,  "used": 14,  "remaining": 6,   "resetAt": "2026-06-02T10:00:00Z" },
        "perDay":  { "limit": 200, "used": 142, "remaining": 58,  "resetAt": "2026-06-02T17:00:00Z" }
      },
      "video": {
        "perHour": { "limit": 5,   "used": 3,   "remaining": 2,   "resetAt": "2026-06-02T10:00:00Z" },
        "perDay":  { "limit": 40,  "used": 28,  "remaining": 12,  "resetAt": "2026-06-02T17:00:00Z" }
      }
    },
    {
      "profileId": "p_2d71b40a9c",
      "profileName": "image-only-02",
      "tags": ["prod", "imageonly"],
      "image": {
        "perHour": { "limit": null, "used": 9,   "remaining": null, "resetAt": "2026-06-02T10:00:00Z" },
        "perDay":  { "limit": 150,  "used": 84,  "remaining": 66,   "resetAt": "2026-06-02T17:00:00Z" }
      },
      "video": null
    }
  ],
  "total": 12,
  "offset": 0,
  "limit": 20
}
```

`resetAt` luôn là UTC instant tương ứng với mốc next-hour / next-VN+7-midnight. `remaining` = `null` khi `limit` không set (= 0 hoặc null trong DB) — UI render "unlimited".

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" \
  "http://localhost:5001/api/v1/reports/quota-usage?search=veo3&limit=50"
```

---

### `GET /api/v1/reports/by-day.csv`

Phiên bản CSV của `/by-day` để admin download mở Excel. Cùng query params, cùng dữ liệu, trả về `text/csv; charset=utf-8` với header `Content-Disposition: attachment; filename=report-by-day-YYYYMMDD-YYYYMMDD.csv`.

**Query params:**

| Field | Type | Required | Mô tả |
|---|---|---|---|
| `from` | ISO datetime (UTC) | ✓ | Mốc đầu khoảng. |
| `to` | ISO datetime (UTC) | ✓ | Mốc cuối khoảng, exclusive. |

**Response 200** — `text/csv`:
```
Date,Image_Succeeded,Image_Total,Image_Limit,Video_Succeeded,Video_Total,Video_Limit,R2V_Succeeded,R2V_Total,F2V_Succeeded,F2V_Total,Total_Succeeded,Total_Total
2026-06-02,612,701,4400,104,138,880,31,42,9,11,756,892
2026-06-01,1230,1402,4400,208,270,880,56,77,15,20,1509,1769
```

**Status codes:** `200 OK` | `403 Forbidden`

#### curl
```bash
curl -H "X-API-Key: $FASTAFF_KEY" -OJ \
  "http://localhost:5001/api/v1/reports/by-day.csv?from=2026-05-26T00:00:00Z&to=2026-06-02T17:00:00Z"
```

---

## Models

### ProfileResponse
```typescript
{
  id: string;
  name: string;
  email: string | null;
  projectId: string | null;
  sessionId: string | null;
  status: "active" | "cooldown" | "banned" | "needs_relogin";
  failCount: number;
  lastUsedAt: string | null;      // ISO 8601 UTC
  createdAt: string;              // ISO 8601 UTC
  hasBearer: boolean;
  hasCookie: boolean;
  proxy: string | null;           // redacted host:port only (không có user:pass)
  hasProxyAuth: boolean;
  hasProxyRotationKey: boolean;
  proxyMode: "none" | "static" | "rotation";
  captchaServerUrl: string | null;
  bearerUpdatedAt: string | null; // khi bearer được paste-save lần cuối
  tags: string[];                 // [] khi không có tag
  notes: string | null;           // ghi chú operator
  captchaMode: "fresh" | "profile";
  paygateTier: "PAYGATE_TIER_ONE" | "PAYGATE_TIER_TIER1P5" | null;  // Veo3 Pro / Ultra
  quotaEnabled: boolean;          // Phase 52
  imageQuotaPerHour: number;      // 0 = unlimited
  imageQuotaPerDay: number;
  videoQuotaPerHour: number;
  videoQuotaPerDay: number;
}
```

### ProfileSecretsResponse
```typescript
// Chỉ trả cho admin qua GET /api/v1/profiles/{id}/secrets — secret đã giải mã.
{
  bearerToken: string | null;
  cookieJson: string | null;      // cookie JSON array (export shape)
  proxy: string | null;           // FULL proxy (gồm user:pass nếu có)
  proxyRotationKey: string | null;
}
```

### TagResponse
```typescript
// Trả bởi GET/POST/PATCH /api/v1/tags — gộp metadata + profile count.
{
  name: string;                   // chính tả nguyên (case-preserve khi managed)
  color: string;                  // hex #RRGGBB. Default "#9ca3af" (gray) khi implicit.
  profileCount: number;           // số profile đang dùng tag này (đếm CSV)
  isManaged: boolean;             // true = có row trong tag_meta; false = chỉ trong CSV
  createdAt: string | null;       // ISO 8601 UTC, null nếu implicit
  updatedAt: string | null;       // ISO 8601 UTC, null nếu implicit
}
```

### LibraryItemResponse
```typescript
{
  id: number;
  mediaId: string;                // mediaId của Google — tái dùng trong R2V
  profileId: string;
  projectId: string;
  fileName: string;
  mimeType: string;               // image/jpeg | image/png | image/webp
  sizeBytes: number;
  width: number | null;
  height: number | null;
  uploadedAt: string;             // ISO 8601 UTC
  lastUsedAt: string | null;      // ISO 8601 UTC
  useCount: number;
}
```

### LibraryListResponse
```typescript
{
  totalCount: number;
  items: LibraryItemResponse[];
}
```

### ImageJobResponse
```typescript
{
  jobId: string;
  profileId: string;
  status: "queued" | "running" | "succeeded" | "failed";
  prompt: string;
  aspectRatio: string | null;
  model: string | null;
  seed: number | null;
  imageUrl: string | null;        // public URL (có thể null khi chưa xong)
  error: string | null;
  createdAt: string;              // ISO 8601 UTC
  completedAt: string | null;     // ISO 8601 UTC
  captchaMode: string | null;     // Phase 46 snapshot at INSERT
  proxyMode: string | null;       // Phase 46 snapshot at INSERT
  billedAmount: number | null;    // VND debited khi job succeeded (null khi free/admin)
  referenceImagesJson: string | null;  // Phase 49 — JSON array of refs khi I2I
}
```

### EnqueuedJobResponse (async image)
```typescript
{
  jobId: string;
  status: "queued";
  webhookSecret: string;          // HMAC key để verify callback
}
```

### VideoJobResponse
```typescript
{
  jobId: string;
  profileId: string;
  status: "queued" | "running" | "polling" | "succeeded" | "failed";
  prompt: string;
  aspectRatio: string | null;
  videoModelKey: string | null;
  seed: number | null;
  videoUrl: string | null;        // public URL (có thể null khi chưa xong)
  error: string | null;
  createdAt: string;              // ISO 8601 UTC
  completedAt: string | null;     // ISO 8601 UTC
  outboundIp: string | null;      // Phase 45
  captchaMode: string | null;     // Phase 46 snapshot at INSERT
  proxyMode: string | null;       // Phase 46 snapshot at INSERT
  billedAmount: number | null;    // VND debited khi succeeded
}
```

### EnqueuedVideoResponse
```typescript
{
  jobId: string;
  status: "queued";
  webhookSecret: string | null;   // null khi không có webhookUrl
}
```

### R2VJobResponse
```typescript
{
  jobId: string;
  profileId: string;
  status: "queued" | "running" | "polling" | "succeeded" | "failed";
  prompt: string;
  aspectRatio: string | null;
  videoModelKey: string | null;
  seed: number | null;
  videoUrl: string | null;        // public mp4 URL khi 'succeeded'
  error: string | null;
  createdAt: string;              // ISO 8601 UTC
  completedAt: string | null;     // ISO 8601 UTC
  referenceCount: number;         // số ảnh tham chiếu đã upload
  referenceFileNames: string[];   // tên file gốc, theo thứ tự upload
  outboundIp: string | null;      // Phase 45
  captchaMode: string | null;     // Phase 46 snapshot at INSERT
  proxyMode: string | null;       // Phase 46 snapshot at INSERT
  billedAmount: number | null;    // VND debited khi succeeded
}
```

### EnqueuedR2VResponse
```typescript
{
  jobId: string;
  status: "queued";
  webhookSecret: string | null;   // null khi không có webhookUrl
  referenceCount: number;         // số ảnh đã được lưu trong job
}
```

### ApiKeyResponse
```typescript
{
  id: number;
  prefix: string;
  label: string;
  isAdmin: boolean;
  isActive: boolean;
  maxConcurrent: number;
  createdAt: string;              // ISO 8601 UTC
  lastUsedAt: string | null;      // ISO 8601 UTC
}
```

### MeResponse
```typescript
{
  id: number;
  prefix: string;
  label: string;
  isAdmin: boolean;
}
```

### BearerSyncConfig
```typescript
{
  enabled: boolean;
  host: string;
  port: number;                   // 1-65535, default 3306
  database: string;
  username: string;
  password: string | null;        // null trong GET response
  hasPassword: boolean;
  useSsl: boolean;
  tableName: string;              // default "bearer_pool"
  colProfileId: string;           // default "profile_id"
  colBearerToken: string;         // default "bearer_token"
  colUpdatedAt: string;           // default "updated_at"
  colCookieJson: string;          // default "cookie_json"; blank = skip cookie sync
  intervalMinutes: number;        // 5-240, default 30
  lastSyncAt: string | null;
  lastSyncedCount: number | null;
  lastSyncStatus: string | null;
}
```

### CaptchaSettingsResponse
```typescript
{
  serverUrl: string;
  siteKey: string;
  actionName: string;
  websiteUrl: string;
  timeoutSeconds: number;
  siteKeyOverridden: boolean;
  actionOverridden: boolean;
  serverUrlPerProfile: boolean;
}
```


### F2VJobResponse
```typescript
{
  jobId: string;
  profileId: string;
  status: "queued" | "running" | "polling" | "succeeded" | "failed";
  prompt: string;
  aspectRatio: string | null;
  videoModelKey: string | null;
  seed: number | null;
  videoUrl: string | null;          // public mp4 URL khi 'succeeded'
  error: string | null;
  createdAt: string;                // ISO 8601 UTC
  completedAt: string | null;       // ISO 8601 UTC
  startFrameFileName: string | null;
  endFrameFileName: string | null;
  outboundIp: string | null;        // Phase 45: outbound IP detected khi gen
  captchaMode: string | null;       // Phase 46 snapshot at INSERT
  proxyMode: string | null;         // Phase 46 snapshot at INSERT
}
```

### EnqueuedF2VResponse
```typescript
{
  jobId: string;
  status: "queued";
  webhookSecret: string | null;     // null khi không có webhookUrl
}
```

### GalleryVideoRow (Phase 48)
```typescript
{
  source: "t2v" | "r2v" | "f2v";    // route lookup chi tiết tới controller tương ứng
  jobId: string;
  profileId: string;
  prompt: string;
  status: string;                   // status của job (succeeded/failed/...)
  aspectRatio: string | null;
  createdAt: string;                // ISO 8601 UTC
}
```

### WalletSnapshotResponse
```typescript
{
  apiKeyId: number;
  balance: number;                  // credit khả dụng (đơn vị VND)
  held: number;                     // credit đang reserved cho gen đang chạy
  total: number;                    // balance + held
}
```

### WalletLedgerEntry
```typescript
{
  id: number;
  ts: string;                       // ISO 8601 UTC
  type: "debit" | "credit" | "topup" | "adjust" | "release";
  amount: number;                   // dương = vào, âm = ra
  balanceAfter: number;
  reason: string | null;
  jobKind: "image" | "video" | "r2v" | "f2v" | null;
  jobId: string | null;
  adminNote: string | null;         // ghi chú admin (chỉ với topup/adjust)
}
```

### PricesResponse
```typescript
{
  imageUnit: number;                // VND per image job (succeeded)
  videoUnit: number;
  r2vUnit: number;
  f2vUnit: number;
  currency: string;                 // "VND"
  updatedAt: string;                // ISO 8601 UTC
}
```

### PoolAutoDestroyConfig (Phase 44)
```typescript
{
  enabled: boolean;                 // default false
  threshold: number;                // số captcha 403 liên tiếp trước khi auto-DELETE
}
```

### ProfileQuotaUsageResponse (Phase 52)
```typescript
{
  profileId: string;
  image: {
    succeededHour: number;          // count succeeded trong giờ hiện tại
    limitHour: number;              // limit per hour (0 = unlimited)
    reqHour: number;                // tổng request trong giờ (bao gồm fail)
    succeededDay: number;           // theo VN+7 ICT day boundary (Phase 54)
    limitDay: number;
    reqDay: number;
    hourResetAt: string;            // next top-of-hour UTC
    dayResetAt: string;             // next 00:00 VN+7 (ICT) → UTC
  };
  video: { /* same shape as image */ };
}
```

### ProfileFingerprintResponse (IMP-01, Linux-coherent 2026-06-06)
```typescript
// Deterministic từ profileId seed — 29 builtin tuples.
// 2026-06-06 (Option A): toàn pool refactor sang Linux-coherent (Mesa
// llvmpipe / SwiftShader / virgl renderer + kernel-version platformVersion).
// Windows D3D11 strings cũ đã bị migration UPDATE.
{
  ua: string;                       // Linux Chrome 148 UA mặc định
  uaCh: {                           // User-Agent Client Hints
    platform: "Linux" | "Windows";  // bây giờ "Linux" cho 29 builtin
    platformVersion: string;        // kernel "6.5.0" / "6.1.0" / "6.6.0" / "6.8.0"
    architecture: "x86";
    bitness: "64";
    fullVersionList: { brand: string, version: string }[];
  };
  webgl: { vendor: string, renderer: string };  // Mesa/X.org / Google SwiftShader
  hwc: number;                      // hardware concurrency (cores)
  mem: number;                      // device memory GB
  screen: { w: number, h: number, depth: number };
  languages: string[];
  canvasNoise: number;              // [0,1) seed-derived
  audioNoise: number;
  chromeFullVersion: string | null; // A4 (2026-06-06) — optional per-fp Chrome
                                    // full version e.g. "148.0.7778.179".
                                    // NULL = dùng default. Bumps backend
                                    // FpHeaderApplier per-fp UA emission.
}
```

### ReportSummary (Phase 53)
```typescript
{
  image: { succeeded: number, total: number };
  i2I:   { succeeded: number, total: number };  // Phase 49 — image jobs có ref
  t2I:   { succeeded: number, total: number };
  video: { succeeded: number, total: number };
  r2V:   { succeeded: number, total: number };
  f2V:   { succeeded: number, total: number };
  total: { succeeded: number, total: number };
  activeProfiles: number;
  totalProfiles: number;
  from: string;                     // ISO 8601 UTC
  to:   string;                     // ISO 8601 UTC
}
```

### ReportByDayRow
```typescript
{
  date: string;                     // YYYY-MM-DD theo VN+7 ICT (Phase 54)
  image: number;
  video: number;
  r2v: number;
  f2v: number;
  spend: number;                    // VND
}
```

### ReportByProfileRow
```typescript
{
  profileId: string;
  profileName: string;
  image: number;
  video: number;
  r2v: number;
  f2v: number;
  spend: number;
  total: number;
}
```

### ReportQuotaUsageRow (Phase 56)
```typescript
{
  profileId: string;
  name: string;
  tags: string[];                   // Phase 56 — cột tag trong UI Reports
  image: {
    succeededHour: number, limitHour: number, reqHour: number,
    succeededDay: number,  limitDay: number,  reqDay: number,
  };
  video: { /* same shape */ };
}
```
### ConcurrencySettingsResponse
```typescript
{
  value: number;
  max: number;
  delayMinSeconds: number;
  delayMaxSeconds: number;
  delayMaxAllowed: number;
  globalValue: number;            // tổng pipeline đồng thời toàn API
  globalMax: number;              // trần cứng cho globalValue
}
```

---

## Errors

| Status | Khi nào |
|---|---|
| `202` | Job đã queued — applies cho async paths: image (có `webhookUrl`), video, R2V, F2V |
| `400` | Validation fail (vd thiếu `prompt`, proxy format sai, webhook URL không hợp lệ, R2V/F2V mime không hợp lệ, profile không có `projectId`) — body có `error` + `reason` |
| `401` | Sai/thiếu `X-API-Key` |
| `402` | Payment Required — wallet không đủ credit để enqueue gen (xem [Wallets](#wallets)) — body có `pricePer` |
| `403` | Key hợp lệ nhưng không đủ quyền admin (`/admin/keys/*`, `/admin/wallets/*`, `/reports/*`, `/videos/cancel-queued`, `/r2v/cancel-queued`, `/f2v/cancel-queued`, `/settings/bearer-sync/*`, `/settings/prices`, `/settings/pool-auto-destroy/*`) hoặc API key bị scope khác profile (`reason: "profile_scope"`) |
| `404` | Profile/Job/ApiKey/Reference index không tồn tại |
| `409` | Conflict — vd cố huỷ video/R2V/F2V job đã `running`/`polling`/terminal (body có `reason: "not_cancellable"` hoặc `"claim_race"`) |
| `429` | Vượt concurrency cap (`maxConcurrent`) HOẶC vượt per-profile quota (Phase 52, body có `reason: "quota_exceeded"` + `Retry-After`) — header `Retry-After: <seconds>` |
| `502` | Gen flow fail (captcha unreachable, Google Labs reject, v.v.) — body có `error` |

**R2V-specific reason codes** (trong field `reason` của 400):

| reason | Nghĩa |
|---|---|
| `no_images` | Không có nguồn ảnh nào — cả `images` (upload) lẫn `libraryIds` (thư viện) đều rỗng |
| `invalid_mime` | Ảnh có mime ngoài danh sách jpeg/png/webp |
| `invalid_prompt` | Prompt rỗng hoặc > 32768 ký tự |
| `bad_content_type` | Header `Content-Type` không phải `multipart/form-data` |
| `no_references` | Job được enqueue với 0 ảnh (defensive — không xảy ra qua API) |
| `profile_no_project_id` | Profile thiếu `projectId` — set trong Profiles → Edit |
| `profile_not_video_eligible` | Profile chưa gắn tag `video` |
| `no_video_profile` | Round-robin không tìm được profile active nào có tag `video` |

**F2V-specific reason codes** (trong field `reason` của 400, ngoài các code chia chung với R2V):

| reason | Nghĩa |
|---|---|
| `no_start_image` | Thiếu form field `startImage` |
| `no_end_image` | Thiếu form field `endImage` |

**Wallets / billing reason codes** (trong field `reason` của 4xx):

| reason | Nghĩa |
|---|---|
| `insufficient_credit` | Wallet không đủ balance để enqueue (402) — body có `pricePer` |
| `quota_exceeded` | Vượt per-profile image/video/hour/day quota (429) — body có `Retry-After` |
| `admin_required` | Endpoint admin only nhưng caller không có flag `isAdmin` (403) |
| `profile_scope` | API key bị giới hạn `AllowedProfileIds` không gồm `profileId` được yêu cầu (403) |
**Profiles/Library reason codes** (trong field `reason` của 400/404):

| reason | Nghĩa |
|---|---|
| `invalid_proxy` | Chuỗi proxy sai định dạng (POST/PATCH `/profiles`) |
| `invalid_captcha_mode` | `captchaMode` khác `fresh`/`profile` |
| `invalid_paygate_tier` | `paygateTier` khác `PAYGATE_TIER_ONE`/`PAYGATE_TIER_TIER1P5` |
| `missing_profile_id` | Thiếu query `profileId` (Library upload) |
| `profile_not_found` | `profileId` không tồn tại (**404**, không phải 400) |
| `profile_no_bearer` | Profile chưa có bearer token (Library upload) |
| `no_file` | Library upload không kèm file |

---

## Operational

### Tạo crypto key cho production

```powershell
$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes) | clip
# Set as FASTAFF_CRYPTO_KEY env var
```

### Backup
- DB: `data/app.db` — chứa profiles, image jobs, video jobs, settings, API keys.
- Crypto key: `FASTAFF_CRYPTO_KEY` env — mất key = mất toàn bộ bearer/proxy/password đã mã hoá.

### Logs
- Console: real-time.
- File: `data/logs/app-yyyyMMdd.log`.
- Failed Google Labs body dumps: `data/logs/labs-bodies/yyyyMMdd-HHmmss-fff.json`.

---

_API reference last updated: **2026-06-02** — covers through **Phase 56** (Reports table enhancements). Coverage: 93/93 endpoints documented._

