Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion services/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,153 @@ paths:
"500":
$ref: "#/components/responses/ApiError"

/health/live:
get:
tags: [health]
operationId: getHealthLive
summary: Liveness probe
responses:
"200":
description: Process is alive
content:
text/plain:
schema:
type: string

/health/ready:
get:
tags: [health]
operationId: getHealthReady
summary: Readiness probe
responses:
"200":
description: Service is ready
content:
text/plain:
schema:
type: string
"503":
$ref: "#/components/responses/ApiError"

/health/dependencies:
get:
tags: [health]
operationId: getHealthDependencies
summary: Dependency health (database, cache, RPC)
responses:
"200":
description: Dependency status
content:
application/json:
schema:
$ref: "#/components/schemas/AnyObject"
"503":
$ref: "#/components/responses/ApiError"

/metrics:
get:
tags: [health]
operationId: getMetrics
summary: Prometheus metrics
responses:
"200":
description: Metrics in Prometheus text format
content:
text/plain:
schema:
type: string

/api/v1/newsletter/gdpr/request-token:
post:
tags: [newsletter]
operationId: newsletterGdprRequestToken
summary: Request a GDPR verification token by email
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EmailRequest"
responses:
"200":
description: Verification email sent if the address is subscribed
content:
application/json:
schema:
$ref: "#/components/schemas/AnyObject"
"400":
$ref: "#/components/responses/NewsletterResponse"
"429":
$ref: "#/components/responses/NewsletterResponse"

/api/v1/admin/api-keys:
get:
tags: [audit]
operationId: listApiKeys
summary: List active API keys (admin)
security:
- ApiKeyAuth: []
responses:
"200":
description: Active API keys
content:
application/json:
schema:
$ref: "#/components/schemas/AnyObject"
"401":
$ref: "#/components/responses/ApiError"
"500":
$ref: "#/components/responses/ApiError"

/api/v1/admin/api-keys/rotate:
post:
tags: [audit]
operationId: rotateApiKey
summary: Issue a new API key and expire the old one after an overlap window (admin)
security:
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AnyObject"
responses:
"201":
description: New key issued
content:
application/json:
schema:
$ref: "#/components/schemas/AnyObject"
"401":
$ref: "#/components/responses/ApiError"
"500":
$ref: "#/components/responses/ApiError"

/api/v1/admin/api-keys/{id}:
delete:
tags: [audit]
operationId: revokeApiKey
summary: Revoke an API key immediately (admin)
security:
- ApiKeyAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"204":
description: Key revoked
"401":
$ref: "#/components/responses/ApiError"
"404":
$ref: "#/components/responses/ApiError"
"500":
$ref: "#/components/responses/ApiError"

/api/v1/statistics:
get:
tags: [markets]
Expand Down Expand Up @@ -246,7 +393,7 @@ paths:
required: true
schema:
type: string
description: Stellar address of the user
description: Stellar public key (G…, 56 chars, valid checksum)
- $ref: "#/components/parameters/apiVersion"
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/pageSize"
Expand Down
24 changes: 18 additions & 6 deletions services/api/src/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ pub struct ReplayRequest {
pub struct ReplayProgress {
pub from_ledger: u32,
pub events_replayed: usize,
/// Highest ledger of any stored event (or `from_ledger` if none were stored).
#[serde(default)]
pub last_ledger: u32,
pub completed: bool,
}

Expand Down Expand Up @@ -1421,13 +1424,18 @@ impl BlockchainClient {

let events = self.fetch_events_since(from_ledger).await
.map_err(BlockchainError::from_anyhow)?;
let events_replayed = events.len();
let mut events_replayed = 0usize;
let mut last_ledger = from_ledger;
let mut skipped_beyond_tip = false;

for event in events {
// Only store events up to the confirmed tip (idempotent by key)
if event.ledger > confirmed_tip {
skipped_beyond_tip = true;
continue;
}
events_replayed += 1;
last_ledger = last_ledger.max(event.ledger);
let event_key = format!("{}:event:{}", keys::CHAIN_PREFIX, event.id);
self.cache
.set_json(&event_key, &event, Duration::from_secs(30 * 60))
Expand All @@ -1438,13 +1446,17 @@ impl BlockchainClient {
let progress = ReplayProgress {
from_ledger,
events_replayed,
completed: true,
last_ledger,
completed: !skipped_beyond_tip,
};

self.cache
.set_json(&progress_key, &progress, Duration::from_secs(60 * 60))
.await
.map_err(|e| BlockchainError::Internal(e.to_string()))?;
// Only cache a completed replay; an incomplete one must be re-runnable.
if progress.completed {
self.cache
.set_json(&progress_key, &progress, Duration::from_secs(60 * 60))
.await
.map_err(|e| BlockchainError::Internal(e.to_string()))?;
}

tracing::info!(from_ledger, events_replayed, "event replay completed");
Ok(progress)
Expand Down
11 changes: 11 additions & 0 deletions services/api/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,17 @@ impl Database {
Ok(result.rows_affected() > 0)
}

/// Revoke an active API key by id. Returns `true` if a key was revoked.
pub async fn api_key_revoke(&self, id: uuid::Uuid) -> anyhow::Result<bool> {
let result = self.with_timeout("api_key_revoke", sqlx::query(
"UPDATE api_keys SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL",
)
.bind(id)
.execute(&self.pool)).await.map_err(anyhow::Error::from)?;

Ok(result.rows_affected() > 0)
}

/// Validate a raw API key: compute its SHA-256 hash, then return the record
/// if it exists, is not revoked, and is not expired.
pub async fn api_key_validate(&self, key_hash: &str) -> anyhow::Result<Option<ApiKeyRecord>> {
Expand Down
90 changes: 90 additions & 0 deletions services/api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,9 @@ pub async fn blockchain_user_bets(
Path(user): Path<String>,
Query(query): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
if !is_valid_stellar_public_key(&user) {
return Err(ApiError::bad_request("invalid Stellar public key"));
}
let page_size = query.limit();
// cursor encodes the page number (0-based).
// Clamp to a sane maximum before any arithmetic to prevent overflow:
Expand Down Expand Up @@ -1913,6 +1916,81 @@ pub async fn rotate_api_key(
))
}

/// DELETE /api/v1/admin/api-keys/{id}
///
/// Revokes an API key immediately by setting `revoked_at`. Keys are validated
/// against the database on every request (no in-process cache), so the
/// revocation takes effect at once.
pub async fn revoke_api_key(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let revoked = state
.db
.api_key_revoke(id)
.await
.map_err(into_api_error)?;
if !revoked {
return Err(ApiError::not_found("API key not found or already revoked"));
}

let entry = crate::audit::AuditLogEntry {
id: None,
timestamp: chrono::Utc::now(),
actor: "admin".to_string(),
actor_ip: None,
action: "api_key_revoke".to_string(),
resource_type: "api_key".to_string(),
resource_id: Some(id.to_string()),
details: None,
status: crate::audit::AuditStatus::Success,
error_message: None,
request_id: None,
user_agent: None,
};
if let Err(e) = state.audit_logger.log(entry).await {
tracing::error!("Failed to write api-key revoke audit log: {e}");
}
tracing::info!(key_id = %id, "API key revoked");

Ok(StatusCode::NO_CONTENT)
}

/// Returns true if `s` is a valid Stellar public key (`G…`, 56 chars, base32 + CRC16 checksum).
fn is_valid_stellar_public_key(s: &str) -> bool {
if s.len() != 56 || !s.starts_with('G') {
return false;
}
let mut bits: u32 = 0;
let mut nbits = 0;
let mut bytes = Vec::with_capacity(35);
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'2'..=b'7' => c - b'2' + 26,
_ => return false,
} as u32;
bits = (bits << 5) | v;
nbits += 5;
if nbits >= 8 {
nbits -= 8;
bytes.push((bits >> nbits) as u8);
bits &= (1 << nbits) - 1;
}
}
if bytes.len() != 35 || bytes[0] != 6 << 3 {
return false;
}
let mut crc: u16 = 0;
for &b in &bytes[..33] {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x1021 } else { crc << 1 };
}
}
crc.to_le_bytes() == [bytes[33], bytes[34]]
}

/// Response item for GET /api/v1/admin/api-keys
#[derive(Debug, Serialize)]
pub struct ApiKeyListItem {
Expand Down Expand Up @@ -1957,6 +2035,18 @@ mod tests {
use super::*;
use axum::http::StatusCode;

#[test]
fn stellar_public_key_validation() {
assert!(is_valid_stellar_public_key(
"GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX"
));
assert!(!is_valid_stellar_public_key(
"GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZY"
));
assert!(!is_valid_stellar_public_key("short"));
assert!(!is_valid_stellar_public_key(&"G".repeat(56)));
}

/// The HTTP response body from ApiError::internal must never contain the
/// underlying error message — only a generic sanitised string.
/// The root cause is emitted to tracing (and therefore OTel spans) instead.
Expand Down
6 changes: 5 additions & 1 deletion services/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use std::{sync::Arc, time::Duration};
use axum::{
http::{HeaderName, HeaderValue, Method},
middleware,
routing::{get, post},
routing::{delete, get, post},
Router,
};
use tokio::net::TcpListener;
Expand Down Expand Up @@ -456,6 +456,10 @@ async fn main() -> anyhow::Result<()> {
"/api/v1/admin/api-keys/rotate",
post(handlers::rotate_api_key),
)
.route(
"/api/v1/admin/api-keys/:id",
delete(handlers::revoke_api_key),
)
.layer(middleware::from_fn_with_state(
state.clone(),
idempotency::idempotency_middleware,
Expand Down
Loading