# API Endpoints Reference

**Base URL:** `http://localhost:8000` (dev) · `https://api.yourdomain.com` (prod)  
**Auth model:** Three caller types — JWT bearer (users), HMAC-signed API keys (external), static ApiKey header (cron)

---

## Table of Contents

1. [Authentication — `/auth/*`](#1-authentication)
2. [Data — `/data/*`](#2-data) · includes [market-data pull](#post-datamarket-data)
3. [Admin — `/admin/*`](#3-admin) · includes [reveal secret](#get-adminapi-keyskey_idsecret) · [rotate secret](#post-adminapi-keyskey_idrotate)
4. [Internal Jobs — `/internal/jobs/*`](#4-internal-jobs)
5. [System — `/health`](#5-system)
6. [Auth header reference](#6-auth-header-reference)
7. [Error codes](#7-error-codes)

---

## 1. Authentication

> **Caller type:** Human browser / mobile user (JWT)  
> **Rate limit:** Login — 5 req / 15 min / IP · All others — no endpoint-level limit

---

### `POST /auth/login`

Verify credentials, issue a short-lived access token (JWT) and a long-lived refresh token (HttpOnly cookie).

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Content-Type` | Yes | `application/json` |

#### Request body (`application/json`)

| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| `email` | string | Yes | valid email format | Normalised to lowercase before lookup |
| `password` | string | Yes | min 1 char | Plain-text; compared against bcrypt hash |

**Example**
```json
{
  "email": "alice@example.com",
  "password": "MySecureP@ss1"
}
```

#### Response `200 OK`

```json
{
  "access_token": "<JWT>",
  "token_type": "bearer",
  "expires_in": 900
}
```

**Set-Cookie** (automatic, HttpOnly):
```
Set-Cookie: refresh_token=<JWT>; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=604800
```

| Field | Type | Description |
|---|---|---|
| `access_token` | string | HS256 JWT — store in memory, never localStorage |
| `token_type` | `"bearer"` | Always `bearer` |
| `expires_in` | integer | Seconds until access token expires (default 900) |

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Wrong email or password |
| `429 Too Many Requests` | Account locked (≥5 failed attempts) or IP rate limit hit |
| `422 Unprocessable Entity` | Invalid request body (missing fields, bad email format) |

---

### `POST /auth/refresh`

Silently rotate the refresh token and return a new access token. Detects token theft via jti reuse.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Cookie` | Yes | `refresh_token=<JWT>` (sent automatically by the browser) |

No request body.

#### Response `200 OK`

```json
{
  "access_token": "<new JWT>",
  "token_type": "bearer",
  "expires_in": 900
}
```

**Set-Cookie** (automatic): new `refresh_token` cookie replacing the old one.

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing cookie, tampered JWT, expired token, or token not found in DB |
| `401 Unauthorized` | **Token theft detected** — entire session family revoked, re-login required |

> **Theft detection:** if a previously-rotated (revoked) jti is presented, all refresh tokens in that login session's family are immediately revoked.

---

### `POST /auth/logout`

Revoke the current refresh token and clear the cookie. Best-effort: does not error if cookie is already invalid.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Cookie` | Optional | `refresh_token=<JWT>` — revoked if present |

No request body.

#### Response `200 OK`

```json
{
  "message": "Logged out successfully"
}
```

**Set-Cookie**: `refresh_token` cookie is expired/deleted.

> **Note:** The access token remains valid for up to 15 min (its natural TTL). For immediate invalidation, implement a Redis blocklist in `verify_user_jwt()`.

#### Error responses

| Status | Condition |
|---|---|
| `200 OK` | Always — logout is idempotent even with an invalid/missing cookie |

---

## 2. Data

> **Caller types:** User JWT (viewer/analyst/admin) · HMAC API key (scoped)  
> **Rate limit:** 1000 req / min / user\_id or key\_id (Redis counter)

---

### `GET /data/pull/{dataset}`

Retrieve a named dataset on demand.

#### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `dataset` | string | Yes | Dataset name. Allowed: `prices`, `volumes`, `signals`, `alerts` |

#### Request headers — User JWT caller

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <access_token>` |

#### Request headers — HMAC API key caller

| Header | Required | Value | Description |
|---|---|---|---|
| `X-Service-Key` | Yes | `<key_id UUID>` | Public key identifier |
| `X-Timestamp` | Yes | Unix epoch seconds (string) | Must be within ±300 s of server time |
| `X-Signature` | Yes | `sha256=<hex>` | HMAC-SHA256 signature — see [signing protocol](#hmac-signing-protocol) |

#### RBAC

| Caller | Required role / scope |
|---|---|
| User JWT | `viewer`, `analyst`, or `admin` |
| API key | scope `data:pull` |
| Cron key | **Rejected** |

#### Response `200 OK`

```json
{
  "dataset": "prices",
  "records": [],
  "fetched_at": "2026-06-02T05:45:00.000000Z",
  "caller": "user"
}
```

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing or invalid auth |
| `403 Forbidden` | Authenticated but insufficient role/scope |
| `404 Not Found` | Dataset name not in allowlist |
| `429 Too Many Requests` | Rate limit exceeded (includes `Retry-After` header) |

---

### `POST /data/market-data`

PostgreSQL port of the legacy `market-data.php` pull API. Reads the IMDS tables populated by `service_1` (`imds_idx_data`, `imds_man_data`, `imds_mkistat_data`, `imds_trd_data`). The request/response contract matches the PHP version so existing consumers work unchanged.

#### Request headers — User JWT caller

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <access_token>` |
| `Content-Type` | Yes | `application/json` |

#### Request headers — S2S API key (Bearer secret — simplest)

Use this when migrating from the old PHP `Authorization: Bearer <static_secret>` style.

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <api_secret>` — the secret from `POST /admin/api-keys` (or rotate) |
| `X-Service-Key` | Recommended | `<key_id UUID>` — public key id from the same response |
| `Content-Type` | Yes | `application/json` |

Do **not** send `X-Timestamp` / `X-Signature` in this mode.

```bash
curl -X POST http://127.0.0.1:8000/data/market-data \
  -H "Authorization: Bearer YOUR_API_SECRET" \
  -H "X-Service-Key: YOUR_KEY_ID_UUID" \
  -H "Content-Type: application/json" \
  -d '{"action":"fetch_new_data","table":"imds_trd_data","last_id":0,"batch_size":100}'
```

> The API secret is **not** a JWT. If you logged in via `/auth/login`, use that JWT in `Bearer` instead (user auth).  
> The key must include scope `data:pull` or you will get `403 Forbidden`.

#### Request headers — HMAC API key caller (S2S, replay-safe)

| Header | Required | Value |
|---|---|---|
| `X-Service-Key` | Yes | `<key_id UUID>` |
| `X-Timestamp` | Yes | Unix epoch seconds (string), within ±300 s |
| `X-Signature` | Yes | `sha256=<hex>` — HMAC over `"{timestamp}." + body_bytes` |
| `Content-Type` | Yes | `application/json` |

#### RBAC

| Caller | Required role / scope |
|---|---|
| User JWT | `viewer`, `analyst`, or `admin` |
| API key | scope `data:pull` |
| Cron key | **Rejected** |

#### Request body

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `action` | string | Yes | — | `fetch_new_data` · `fetch_all` · `fetch_day_end_data` |
| `table` | string | Yes | — | `imds_idx_data` · `imds_man_data` · `imds_mkistat_data` · `imds_trd_data` |
| `last_id` | integer | No | null | Cursor — return rows with primary key `>` this (ID-based paging, preferred) |
| `primary_key_column` | string | No | table default | Override primary key column (validated against table schema) |
| `last_timestamp` | string | No | null | Cursor — return rows with timestamp column `>` this (fallback paging) |
| `offset` | integer | No | `0` | Offset for offset-based paging (`fetch_all` only) |
| `batch_size` | integer | No | `2000` | Max rows per response (1–5000) |
| `until_date` | string | No | null | `YYYY-MM-DD` inclusive ceiling applied to `timestamp_column` (`fetch_all`) |
| `timestamp_column` | string | No | table default | Timestamp column used with `until_date` |
| `date` | string | No | null | `YYYY-MM-DD` trade date — **required** for `fetch_day_end_data` |

**Pagination precedence:** `last_id` (ID-based, reliable) → `last_timestamp` (fetch_new_data) / `offset` (fetch_all). ID-based is strongly preferred — it cannot drop or duplicate rows under concurrent writes.

**Example — incremental pull**
```json
{
  "action": "fetch_new_data",
  "table": "imds_mkistat_data",
  "last_id": 152340,
  "batch_size": 2000
}
```

**Example — day-end snapshot**
```json
{
  "action": "fetch_day_end_data",
  "table": "imds_mkistat_data",
  "date": "2026-06-03"
}
```

#### Response `200 OK` — `fetch_new_data` / `fetch_all`

```json
{
  "success": true,
  "data": [
    { "MKISTAT_ID": 152341, "MKISTAT_INSTRUMENT_CODE": "ABBANK", "MKISTAT_CLOSE_PRICE": 41.5, "MKISTAT_STORE_TIMESTAMP": "2026-06-03 14:30:00" }
  ],
  "count": 1,
  "has_more": true
}
```

| Field | Type | Description |
|---|---|---|
| `data` | array | Row objects with original UPPERCASE column names. Decimals → numbers, datetimes → `YYYY-MM-DD HH:MM:SS`, dates → `YYYY-MM-DD`. |
| `count` | integer | Rows in this page |
| `has_more` | boolean | More rows exist beyond this page (exact for ID/timestamp paging; estimated for offset paging) |

#### Response `200 OK` — `fetch_day_end_data`

```json
{
  "success": true,
  "data": [ { "MKISTAT_ID": 152341, "MKISTAT_INSTRUMENT_CODE": "ABBANK", "MKISTAT_TOTAL_TRADES": 318 } ],
  "count": 1,
  "date": "2026-06-03"
}
```

Returns the latest non-zero-trade row per `MKISTAT_INSTRUMENT_CODE` for the given `MKISTAT_STORE_DATE`.

#### Error responses

Error bodies preserve the PHP `{error, code}` shape inside FastAPI's `detail` envelope:

| Status | `code` | Condition |
|---|---|---|
| `400 Bad Request` | `INVALID_PARAMETER` | Unknown column name or malformed `until_date` |
| `400 Bad Request` | `MISSING_DATE` | `fetch_day_end_data` without `date` |
| `400 Bad Request` | `INVALID_DATE_FORMAT` | `date` not `YYYY-MM-DD` |
| `400 Bad Request` | `INVALID_TABLE` | `fetch_day_end_data` on a non-`imds_mkistat_data` table |
| `401 Unauthorized` | — | Missing or invalid auth |
| `403 Forbidden` | — | Authenticated but insufficient role/scope (cron rejected) |
| `422 Unprocessable Entity` | — | Body validation failed (bad `action`/`table`, out-of-range `batch_size`) |
| `429 Too Many Requests` | — | Rate limit exceeded |
| `500 Internal Server Error` | `QUERY_FAILED` | Query execution error (details not exposed) |

> **SQL injection note:** unlike the PHP version, client-supplied `primary_key_column` and `timestamp_column` are validated against the table's real columns before use — arbitrary identifiers are rejected with `INVALID_PARAMETER`.

---

### `WS /data/push/subscribe`

WebSocket push-feed subscription. Auth is validated once on connection; key revocation is checked on every heartbeat ping (every 30 s by default).

#### Connection URL

```
ws://localhost:8000/data/push/subscribe
wss://api.yourdomain.com/data/push/subscribe  (production)
```

#### Upgrade request headers — User JWT caller

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <access_token>` |

#### Upgrade request headers — HMAC API key caller

| Header | Required | Value |
|---|---|---|
| `X-Service-Key` | Yes | `<key_id UUID>` |
| `X-Timestamp` | Yes | Unix epoch seconds (string) |
| `X-Signature` | Yes | `sha256=<hex>` — body is empty bytes for WS connect |

#### RBAC

| Caller | Required role / scope |
|---|---|
| User JWT | `analyst` or `admin` (`viewer` is rejected) |
| API key | scope `feed:subscribe` |
| Cron key | **Rejected** |

#### Server → client messages

**Heartbeat ping** (every 30 s):
```json
{
  "type": "ping",
  "timestamp": "2026-06-02T05:45:00.000000Z"
}
```

#### WebSocket close codes

| Code | Meaning |
|---|---|
| `4001` | Unauthorized — missing or invalid credentials |
| `4003` | Forbidden — authenticated but insufficient role/scope |
| `4004` | Key revoked — API key set `is_active=false` during session |
| `1011` | Internal server error |

---

## 3. Admin

> **Caller type:** User JWT with role `admin` only  
> **Auth header:** `Authorization: Bearer <access_token>`

All admin routes require an `admin`-role JWT. API key callers and cron callers cannot access `/admin/*`.

---

### `POST /admin/api-keys`

Issue a new API key. The plain secret is returned **once** and never stored.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |
| `Content-Type` | Yes | `application/json` |

#### Request body

| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
| `service_name` | string | Yes | 1–128 chars | Human label for the subscriber |
| `scopes` | string[] | Yes | min 1 item; valid values below | Permission scopes |
| `rate_limit` | integer | No | 1–10000, default 600 | Max requests per minute |
| `description` | string | No | max 512 chars | Ops team notes |
| `expires_at` | datetime (ISO 8601) | No | null = never expires | Key hard expiry |

**Valid scope values:**

| Scope | Grants access to |
|---|---|
| `data:pull` | `GET /data/pull/{dataset}` |
| `feed:subscribe` | `WS /data/push/subscribe` |
| `data:push` | Push publishing (reserved) |
| `internal:job` | `/internal/jobs/*` (cron only) |

**Example**
```json
{
  "service_name": "analytics-service",
  "scopes": ["data:pull", "feed:subscribe"],
  "rate_limit": 300,
  "description": "Analytics platform — read-only pull + push subscription"
}
```

#### Response `201 Created`

```json
{
  "key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "service_name": "analytics-service",
  "scopes": ["data:pull", "feed:subscribe"],
  "is_active": true,
  "rate_limit": 300,
  "description": "Analytics platform — read-only pull + push subscription",
  "expires_at": null,
  "created_at": "2026-06-02T05:45:00.000000Z",
  "last_used_at": null,
  "secret": "a3f9c2d1e4b7..."
}
```

> **`secret` is shown exactly once.** Store it securely in your secrets manager. It cannot be retrieved again.

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing or invalid JWT |
| `403 Forbidden` | JWT role is not `admin` |
| `422 Unprocessable Entity` | Invalid scopes, missing required fields |

---

### `GET /admin/api-keys`

List all API keys. Secrets are never included in list responses.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |

No request body.

#### Response `200 OK`

Array of key objects (same as `ApiKeyResponse`, without `secret`):

```json
[
  {
    "key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "service_name": "analytics-service",
    "scopes": ["data:pull", "feed:subscribe"],
    "is_active": true,
    "rate_limit": 300,
    "description": "Analytics platform",
    "expires_at": null,
    "created_at": "2026-06-02T05:45:00.000000Z",
    "last_used_at": "2026-06-02T06:10:00.000000Z"
  }
]
```

---

### `GET /admin/api-keys/{key_id}`

Fetch a single API key by ID.

#### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key_id` | UUID | Yes | Key identifier (`key_id` from creation response) |

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |

#### Response `200 OK`

Single `ApiKeyResponse` object (same structure as list item above).

#### Error responses

| Status | Condition |
|---|---|
| `404 Not Found` | Key ID does not exist |

---

### `GET /admin/api-keys/{key_id}/secret`

Reveal the plain-text secret for an existing API key.

> **Security-sensitive operation.** Every call is written to `auth_audit_log` with event type `key_secret_revealed`. Monitor this endpoint for abuse. Prefer key rotation over reuse of revealed secrets.

#### Storage strategy — what can be revealed

| Key type | Storage | Recoverable? |
|---|---|---|
| HMAC subscriber (`data:pull`, `feed:subscribe`, `data:push`) | Fernet-encrypted | **Yes** — secret decrypted and returned |
| Cron / internal job (`internal:job` scope only) | bcrypt hash | **No** — `409` returned; rotate via `POST /admin/api-keys` |

#### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key_id` | UUID | Yes | Key identifier returned at creation time |

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |

No request body.

#### Response `200 OK`

```json
{
  "key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "service_name": "analytics-service",
  "scopes": ["data:pull", "feed:subscribe"],
  "key_type": "hmac",
  "secret": "a3f9c2d1e4b7...",
  "revealed_at": "2026-06-03T04:55:00.000000Z"
}
```

| Field | Description |
|---|---|
| `key_type` | `"hmac"` — Fernet-encrypted, secret decryptable · `"cron"` — bcrypt, not reachable here |
| `secret` | Plain-text secret. Store securely — do not log. |
| `revealed_at` | UTC timestamp of this reveal request. |

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing or invalid JWT |
| `403 Forbidden` | JWT role is not `admin` |
| `404 Not Found` | Key ID does not exist |
| `409 Conflict` | Key is a cron key (bcrypt-hashed) — secret is not recoverable. Rotate the key instead. |

#### Audit log entry

Every successful reveal writes to `auth_audit_log`:

```json
{
  "event_type": "key_secret_revealed",
  "caller_type": "user",
  "user_id": "<admin_user_uuid>",
  "key_id": "<target_key_uuid>",
  "ip_address": "203.0.113.42",
  "metadata": {
    "service_name": "analytics-service",
    "key_type": "hmac"
  }
}
```

#### When to use this endpoint

| Scenario | Recommended action |
|---|---|
| Subscriber lost their secret after initial key creation | Call this endpoint to reveal and re-deliver it securely |
| Rotating credentials as a security best practice | Do **not** use this — issue a new key and revoke the old one |
| Security incident — secret may be compromised | Do **not** use this — revoke immediately via `PATCH`, issue a new key |

---

### `POST /admin/api-keys/{key_id}/rotate`

Rotate (regenerate) the secret for an existing API key **in-place**.

The `key_id` does not change — only the secret value is replaced. The subscriber updates only their stored secret, not their full integration config.

> **New secret is shown exactly once.** The previous secret is invalid the moment this response is sent. Store the new secret immediately.

#### When to rotate vs. when to issue a new key

| Situation | Recommended action |
|---|---|
| Scheduled hygiene (e.g. every 90 days) | `POST /rotate` — same `key_id`, new secret |
| Secret may be compromised | `POST /rotate` (or revoke + new key for full replacement) |
| Subscriber needs new permissions / scopes | Issue a new key (`POST /admin/api-keys`) — scopes cannot be changed in-place |
| Revoked key needs restoring | Reactivate via `PATCH` first, then `POST /rotate` if secret is also unknown |

#### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key_id` | UUID | Yes | Key to rotate |

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |
| `Content-Type` | Yes | `application/json` |

#### Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | No | max 256 chars — stored in audit log |

```json
{
  "reason": "Quarterly credential rotation — scheduled hygiene"
}
```

#### Response `200 OK`

```json
{
  "key_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "service_name": "analytics-service",
  "scopes": ["data:pull", "feed:subscribe"],
  "is_active": true,
  "rate_limit": 300,
  "description": "Analytics platform",
  "expires_at": null,
  "created_at": "2026-06-02T05:45:00.000000Z",
  "last_used_at": "2026-06-03T04:00:00.000000Z",
  "new_secret": "d8f2a1c3e9b4...",
  "rotated_at": "2026-06-03T05:00:00.000000Z",
  "rotation_reason": "Quarterly credential rotation — scheduled hygiene"
}
```

| Field | Description |
|---|---|
| `new_secret` | New plain-text secret. Store securely — not retrievable again. |
| `rotated_at` | UTC timestamp of the rotation. |
| `rotation_reason` | Echoed from request body for confirmation. |

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing or invalid JWT |
| `403 Forbidden` | JWT role is not `admin` |
| `404 Not Found` | Key ID does not exist |
| `409 Conflict` | Key is revoked (`is_active=false`) — reactivate it first or issue a new key |

#### Audit log entry

```json
{
  "event_type": "key_rotated",
  "caller_type": "user",
  "user_id": "<admin_user_uuid>",
  "key_id": "<target_key_uuid>",
  "ip_address": "203.0.113.42",
  "metadata": {
    "service_name": "analytics-service",
    "key_type": "hmac",
    "reason": "Quarterly credential rotation — scheduled hygiene"
  }
}
```

---

### `PATCH /admin/api-keys/{key_id}`

Revoke or reactivate an API key.

#### Path parameters

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key_id` | UUID | Yes | Key to update |

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `Bearer <admin_access_token>` |
| `Content-Type` | Yes | `application/json` |

#### Request body

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `is_active` | boolean | No | `false` | `false` = revoke immediately · `true` = reactivate |
| `reason` | string | No | null | max 256 chars — documented reason for audit trail |

**Revoke example:**
```json
{
  "is_active": false,
  "reason": "Credential rotation — 24h grace period starting now"
}
```

#### Response `200 OK`

Updated `ApiKeyResponse` object.

> **WebSocket impact:** Active WebSocket connections using a revoked key will be closed with code `4004` at the next heartbeat ping (within 30 s).

#### Error responses

| Status | Condition |
|---|---|
| `404 Not Found` | Key ID does not exist |

---

## 4. Internal Jobs

> **Caller type:** Cron / scheduled job (static `ApiKey` header)  
> **Network:** VPC / private subnet only — blocked at nginx/ALB for public traffic  
> **Concurrency:** Max 1 execution per job type (Redis SETNX distributed lock)

---

### `GET /internal/jobs/health`

Liveness probe — **no authentication required**.

#### Response `200 OK`

```json
{
  "status": "ok",
  "timestamp": "2026-06-02T05:45:00.000000Z"
}
```

> Used by AWS ALB / Kubernetes readiness probes. This endpoint must remain open even when `/internal/*` is blocked for public traffic.

---

### `POST /internal/jobs/collect`

Trigger data collection from configured external sources.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `ApiKey <raw_key>` |

No request body.

#### Response `202 Accepted`

```json
{
  "status": "completed",
  "records_processed": 142
}
```

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Missing, invalid, or inactive cron key |
| `403 Forbidden` | Key exists but lacks `internal:job` scope |
| `409 Conflict` | Job already running (Redis lock held by another instance) |
| `500 Internal Server Error` | Collection logic failed |

---

### `POST /internal/jobs/cleanup`

Purge expired refresh tokens. Deletes rows where `expires_at < NOW() - 1 day` (1-day grace for forensics).

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `ApiKey <raw_key>` |

No request body.

#### Response `202 Accepted`

```json
{
  "status": "completed",
  "records_deleted": 87
}
```

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Invalid cron key |
| `409 Conflict` | Already running |
| `500 Internal Server Error` | DB delete failed |

---

### `POST /internal/jobs/push-digest`

Send batched push updates to all active WebSocket subscribers.

#### Request headers

| Header | Required | Value |
|---|---|---|
| `Authorization` | Yes | `ApiKey <raw_key>` |

No request body.

#### Response `202 Accepted`

```json
{
  "status": "completed",
  "records_sent": 34
}
```

#### Error responses

| Status | Condition |
|---|---|
| `401 Unauthorized` | Invalid cron key |
| `409 Conflict` | Already running |
| `500 Internal Server Error` | Push logic failed |

---

## 5. System

### `GET /health`

Global application health check. No authentication.

#### Response `200 OK`

```json
{ "status": "ok" }
```

---

## 6. Auth Header Reference

### Caller Type 1 — JWT Bearer (human user)

```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

JWT payload (read-only — signed, not encrypted):
```json
{
  "sub": "<user_uuid>",
  "role": "viewer | analyst | admin",
  "type": "access",
  "iat": 1748845500,
  "exp": 1748846400
}
```

| Property | Value |
|---|---|
| Algorithm | HS256 |
| Lifetime | 900 s (15 min) |
| Storage | In-memory only — never `localStorage` |

---

### Caller Type 2 — HMAC-signed API key (external subscriber)

```http
X-Service-Key: 3fa85f64-5717-4562-b3fc-2c963f66afa6
X-Timestamp: 1748845500
X-Signature: sha256=a3f9c2d1e4b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3
```

#### HMAC signing protocol

```python
import hmac, hashlib, time

timestamp  = str(int(time.time()))          # Unix epoch seconds
payload    = f"{timestamp}.".encode() + request_body_bytes
signature  = hmac.new(
    secret.encode(),
    payload,
    hashlib.sha256
).hexdigest()

# Headers to send:
# X-Service-Key : <key_id>
# X-Timestamp   : <timestamp>
# X-Signature   : sha256=<signature>
```

| Rule | Detail |
|---|---|
| Clock tolerance | ±300 s — requests outside window are rejected as replays |
| Body for WS connect | Empty bytes (`b""`) |
| Comparison | Always `hmac.compare_digest()` — never `==` |

---

### Caller Type 3 — Static ApiKey (internal cron)

```http
Authorization: ApiKey a3f9c2d1e4b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3
```

| Property | Value |
|---|---|
| Format | `ApiKey <raw_key>` (note: capital A, capital K, single space) |
| Storage | `api_keys` table, `scopes = ['internal:job']` |
| Verification | bcrypt compare (one-way) |
| No HMAC | Justified by VPC network boundary; use Type 2 if jobs run outside VPC |

---

## 7. Error Codes

All error responses follow the same envelope:

```json
{
  "detail": "Human-readable error message"
}
```

| HTTP Status | When it occurs |
|---|---|
| `200 OK` | Success (GET, POST /auth/logout) |
| `201 Created` | New resource created (POST /admin/api-keys) |
| `202 Accepted` | Job triggered (POST /internal/jobs/*) |
| `401 Unauthorized` | Missing auth, invalid token/key/signature, expired token |
| `403 Forbidden` | Authenticated but wrong role or missing scope |
| `404 Not Found` | Resource does not exist |
| `409 Conflict` | Concurrent job already running (Redis lock) |
| `422 Unprocessable Entity` | Request body validation failed (Pydantic) |
| `429 Too Many Requests` | Rate limit exceeded — check `Retry-After` response header |
| `500 Internal Server Error` | Unhandled server error — details never exposed to client |

---

## Quick-start examples

### Login and call a protected endpoint (curl)

```bash
# 1. Login — save cookie and extract access token
RESPONSE=$(curl -s -c cookies.txt -X POST http://localhost:8000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secret"}')

ACCESS=$(echo $RESPONSE | python -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# 2. Pull data
curl -s http://localhost:8000/data/pull/prices \
  -H "Authorization: Bearer $ACCESS"

# 3. Silent refresh (cookie sent automatically)
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8000/auth/refresh

# 4. Logout
curl -s -b cookies.txt -X POST http://localhost:8000/auth/logout
```

### HMAC-signed request (Python)

```python
import hmac, hashlib, time, requests

KEY_ID = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
SECRET = "a3f9c2d1..."   # from POST /admin/api-keys response

def signed_get(url: str) -> requests.Response:
    body = b""
    ts   = str(int(time.time()))
    sig  = hmac.new(SECRET.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    return requests.get(url, headers={
        "X-Service-Key": KEY_ID,
        "X-Timestamp":   ts,
        "X-Signature":   f"sha256={sig}",
    })

resp = signed_get("http://localhost:8000/data/pull/prices")
print(resp.json())
```

### Cron job trigger (curl)

```bash
curl -s -X POST http://localhost:8000/internal/jobs/collect \
  -H "Authorization: ApiKey a3f9c2d1e4b7c8d9..."
```
