diff --git a/API_SPEC.md b/API_SPEC.md index 27fb5f72..74f2e9cf 100644 --- a/API_SPEC.md +++ b/API_SPEC.md @@ -107,7 +107,8 @@ Quick reference for the most common codes: The API implements rate limiting to ensure fair usage: -- **Rate Limit:** 1000 requests per minute per API key +- **Global limit:** 100 requests per 60 seconds per client IP (`GLOBAL_RATE_LIMIT_MAX`, `GLOBAL_RATE_LIMIT_WINDOW_SECS`) +- **Admin routes:** 30 requests per 60 seconds (`ADMIN_RATE_LIMIT_MAX`, `ADMIN_RATE_LIMIT_WINDOW_SECS`) - **Headers:** - `X-RateLimit-Limit`: Maximum requests per window - `X-RateLimit-Remaining`: Requests remaining in current window diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index e6191c93..258bfe8e 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -40,6 +40,7 @@ |--------|------|-----------|------| | POST | `/api/v1/markets/{market_id}/resolve` | `resolveMarket` | ApiKeyAuth | | POST | `/api/v1/markets/{market_id}/cancel` | `cancelMarket` | ApiKeyAuth | +| POST | `/api/v1/blockchain/replay` | `blockchainReplay` | ApiKeyAuth | | POST | `/api/blockchain/replay` | `blockchainReplay` | ApiKeyAuth | | GET | `/api/v1/email/preview/{template_name}` | `emailPreview` | ApiKeyAuth | | POST | `/api/v1/email/test` | `emailSendTest` | ApiKeyAuth | diff --git a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts index 2fe050af..a5d0b2c6 100644 --- a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts +++ b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts @@ -154,7 +154,7 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => { '/api/v1/email/test', '/api/v1/email/analytics', '/api/v1/email/queue/stats', - '/api/blockchain/replay', + '/api/v1/blockchain/replay', ]); const PLACEHOLDER = new RegExp(String.raw`\$\{[^}]+\}`, 'g'); diff --git a/frontend/src/lib/api/rateLimit.ts b/frontend/src/lib/api/rateLimit.ts index 98644b57..a66e0c9e 100644 --- a/frontend/src/lib/api/rateLimit.ts +++ b/frontend/src/lib/api/rateLimit.ts @@ -1,7 +1,7 @@ /** * Rate-limit signal bus (#1339). * - * `API_SPEC.md`: 1000 req/min per key; a 429 carries `Retry-After` (seconds). The + * `API_SPEC.md`: 100 req/60s per IP (30/min on admin routes); a 429 carries `Retry-After` (seconds). The * client reports every 429 here; the UI subscribes to show one shared countdown. * * Coalescing: while a cooldown window is active, further 429s from parallel requests diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 6f84b61e..f4cf581a 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -371,7 +371,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/blockchain/replay": { + "/api/v1/blockchain/replay": { parameters: { query?: never; header?: never; diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 3d154ba2..a0050dc0 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -783,7 +783,7 @@ paths: "500": $ref: "#/components/responses/ApiError" - /api/blockchain/replay: + /api/v1/blockchain/replay: post: tags: [blockchain] operationId: blockchainReplay @@ -815,6 +815,14 @@ paths: summary: List dead-letter email jobs (admin) security: - ApiKeyAuth: [] + parameters: + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + - name: cursor + in: query + description: Opaque cursor from a previous response's `next_cursor` + schema: { type: string } responses: "200": description: Dead-letter job list diff --git a/services/api/scripts/run_migrations.sh b/services/api/scripts/run_migrations.sh index 3152e2bf..8857d2bf 100755 --- a/services/api/scripts/run_migrations.sh +++ b/services/api/scripts/run_migrations.sh @@ -17,7 +17,8 @@ # How it works: # 1. Ensures the schema_migrations tracking table exists (000_ migration). # 2. For each *.sql file in migrations/ (sorted by filename): -# a. Derives the version from the numeric prefix (e.g. "001"). +# a. Uses the full filename stem as the version (e.g. "017_create_api_keys"), +# so files sharing a numeric prefix are all applied. # b. Skips the file if that version is already recorded. # c. Computes a SHA-256 checksum of the file. # d. Runs the SQL inside a transaction. @@ -74,8 +75,9 @@ ensure_tracking_table() { is_applied() { local version="$1" local count + # Legacy rows stored only the numeric prefix; honour them when the recorded name matches. count=$(psql_exec -t -c \ - "SELECT COUNT(*) FROM schema_migrations WHERE version = '$version';" \ + "SELECT COUNT(*) FROM schema_migrations WHERE version = '$version' OR (version = '${version%%_*}' AND name = '$version');" \ 2>/dev/null | tr -d '[:space:]') [[ "$count" == "1" ]] } @@ -119,19 +121,19 @@ cmd_status() { for file in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do local fname version name fname="$(basename "$file")" - version="${fname%%_*}" name="${fname%.sql}" + version="$name" if is_applied "$version"; then local row row=$(psql_exec -t -c \ - "SELECT applied_at, checksum FROM schema_migrations WHERE version = '$version';" \ + "SELECT applied_at, checksum FROM schema_migrations WHERE version = '$version' OR (version = '${version%%_*}' AND name = '$version');" \ | tr -d '[:space:]') local applied_at="${row%%|*}" local chk="${row##*|}" - printf " %-8s %-50s %-26s %s\n" "$version" "$name" "$applied_at" "${chk:0:12}..." + printf " %-8s %-50s %-26s %s\n" "${version%%_*}" "$name" "$applied_at" "${chk:0:12}..." else - printf " %-8s %-50s %-26s %s\n" "$version" "$name" "(pending)" "-" + printf " %-8s %-50s %-26s %s\n" "${version%%_*}" "$name" "(pending)" "-" any_pending=true fi done @@ -149,7 +151,7 @@ cmd_status() { # --------------------------------------------------------------------------- cmd_rollback() { local target_version="$1" - [[ -n "$target_version" ]] || die "--rollback requires a version argument (e.g. --rollback 005)" + [[ -n "$target_version" ]] || die "--rollback requires a version argument (e.g. --rollback 005_create_content_management)" ensure_tracking_table @@ -205,7 +207,7 @@ cmd_run() { for file in $files; do local fname version fname="$(basename "$file")" - version="${fname%%_*}" + version="${fname%.sql}" if is_applied "$version"; then log "Skipping [$version] — already applied" diff --git a/services/api/src/db.rs b/services/api/src/db.rs index 15bcebe0..e5be07fc 100644 --- a/services/api/src/db.rs +++ b/services/api/src/db.rs @@ -973,11 +973,13 @@ impl Database { /// List all dead-letter job IDs from PostgreSQL (oldest-failed first). /// /// Used as a fallback when Redis is unavailable or has been flushed. - pub async fn email_list_dead_letter_jobs(&self) -> anyhow::Result> { + pub async fn email_list_dead_letter_jobs(&self, limit: i64, offset: i64) -> anyhow::Result> { let rows = self .with_timeout( "email_list_dead_letter_jobs", - sqlx::query("SELECT id FROM email_dead_letter_jobs ORDER BY failed_at ASC") + sqlx::query("SELECT id FROM email_dead_letter_jobs ORDER BY failed_at ASC LIMIT $1 OFFSET $2") + .bind(limit) + .bind(offset) .fetch_all(&self.pool), ) .await diff --git a/services/api/src/email/queue.rs b/services/api/src/email/queue.rs index 759d9ef1..351e4ef5 100644 --- a/services/api/src/email/queue.rs +++ b/services/api/src/email/queue.rs @@ -341,10 +341,10 @@ impl EmailQueue { } /// List all job IDs currently in the dead-letter set (oldest-failed first). - pub async fn list_dead_letter(&self) -> Result> { + pub async fn list_dead_letter(&self, offset: isize, limit: isize) -> Result> { let mut conn = self.cache.get_connection().await?; let items: Vec = conn - .zrange(EMAIL_DEAD_LETTER_KEY, 0isize, -1isize) + .zrange(EMAIL_DEAD_LETTER_KEY, offset, offset + limit - 1) .await .context("Failed to list dead-letter set")?; diff --git a/services/api/src/main.rs b/services/api/src/main.rs index b249de0a..52c63c33 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -443,9 +443,14 @@ async fn main() -> anyhow::Result<()> { post(handlers::cancel_market), ) .route( - "/api/blockchain/replay", + "/api/v1/blockchain/replay", post(handlers::blockchain_replay), ) + // Deprecated unversioned alias; use /api/v1/blockchain/replay. + .route( + "/api/blockchain/replay", + post(handlers::blockchain_replay_deprecated), + ) .route( "/api/v1/email/preview/:template_name", get(handlers::email_preview), diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs index 0bd8ef9d..871fe252 100644 --- a/services/api/tests/openapi_contract_test.rs +++ b/services/api/tests/openapi_contract_test.rs @@ -34,7 +34,7 @@ mod tests { ("POST", "/api/v1/email/test"), ("GET", "/api/v1/email/analytics"), ("GET", "/api/v1/email/queue/stats"), - ("POST", "/api/blockchain/replay"), + ("POST", "/api/v1/blockchain/replay"), ("GET", "/api/v1/email/queue/dead-letter"), ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"), ("GET", "/api/v1/audit/logs"), @@ -50,7 +50,7 @@ mod tests { ("POST", "/api/v1/email/test"), ("GET", "/api/v1/email/analytics"), ("GET", "/api/v1/email/queue/stats"), - ("POST", "/api/blockchain/replay"), + ("POST", "/api/v1/blockchain/replay"), ("GET", "/api/v1/email/queue/dead-letter"), ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"), ("GET", "/api/v1/audit/logs"),