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
8 changes: 8 additions & 0 deletions migrations/0002_add_merchants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Merchants are provisioned via POST /merchants.
-- api_key_hash stores SHA-256(raw_key) in hex so the plaintext key is never
-- persisted; the raw key is returned once at creation time and never again.
CREATE TABLE IF NOT EXISTS merchants (
id TEXT PRIMARY KEY,
api_key_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
88 changes: 86 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use axum::{
middleware::{self, Next},
response::IntoResponse,
routing::{get, post},
Json,
Extension, Json,
};
use serde_json::json;
use std::net::SocketAddr;
Expand All @@ -22,6 +22,10 @@ mod payments;
/// Reject request bodies larger than this (256 KiB) before they hit a handler.
const MAX_BODY_BYTES: usize = 256 * 1024;

/// The authenticated merchant ID injected by the auth middleware.
#[derive(Clone)]
pub struct AuthenticatedMerchant(pub String);

pub fn router(state: Arc<AppState>) -> axum::Router {
let cors = build_cors(&state.config);
let rate_limit_rps = state.config.rate_limit_requests_per_sec;
Expand All @@ -30,9 +34,21 @@ pub fn router(state: Arc<AppState>) -> axum::Router {
.route("/", get(|| async { "StellarGate API v0.1.0" }))
.route("/health", get(health))
.route("/ready", get(ready))
// Merchant provisioning — returns a one-time plaintext API key.
.route("/merchants", post(provision_merchant))
.nest("/payments", {
axum::Router::new()
// Auth middleware only on the write + list routes; the per-payment
// status and webhook endpoints stay public (anyone with the id can
// poll or inspect).
let authed = axum::Router::new()
.route("/", post(payments::create).get(payments::list))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));

axum::Router::new()
.merge(authed)
.route("/:id", get(payments::get_by_id))
.route("/:id/webhooks", get(payments::list_webhooks))
.route(
Expand All @@ -54,6 +70,74 @@ pub fn router(state: Arc<AppState>) -> axum::Router {
.with_state(state)
}

async fn auth_middleware(
State(state): State<Arc<AppState>>,
mut req: Request,
next: Next,
) -> axum::response::Response {
let raw_key = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.filter(|k| !k.is_empty())
.map(str::to_string);

let Some(key) = raw_key else {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid Authorization header", "code": "unauthorized" })),
)
.into_response();
};

match db::find_merchant_by_key(&state.pool, &key).await {
Ok(Some(merchant_id)) => {
req.extensions_mut()
.insert(AuthenticatedMerchant(merchant_id));
next.run(req).await
}
Ok(None) => (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid API key", "code": "unauthorized" })),
)
.into_response(),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal server error", "code": "internal_error" })),
)
.into_response(),
}
}

/// `POST /merchants` — provision a new merchant and return its API key once.
/// In production this endpoint should be protected (e.g., by an admin secret
/// or removed entirely in favour of an offline provisioning script).
async fn provision_merchant(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let merchant_id = uuid::Uuid::new_v4().to_string();
let raw_key = uuid::Uuid::new_v4().to_string();

db::create_merchant(&state.pool, &merchant_id, &raw_key)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal server error", "code": "internal_error" })),
)
})?;

Ok((
StatusCode::CREATED,
Json(json!({
"merchant_id": merchant_id,
"api_key": raw_key,
})),
))
}

async fn rate_limit_middleware(
addr: SocketAddr,
rate_limit_rps: u32,
Expand Down
18 changes: 9 additions & 9 deletions src/api/payments.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{db, money, AppState};
use crate::{api::AuthenticatedMerchant, db, money, AppState};
use axum::{
async_trait,
extract::{FromRequest, Path, Query, Request, State},
extract::{Extension, FromRequest, Path, Query, Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
Expand Down Expand Up @@ -94,7 +94,6 @@ pub struct CreatePaymentRequest {
pub amount: String,
#[serde(default = "default_asset")]
pub asset: String,
pub merchant_id: Option<String>,
pub webhook_url: Option<String>,
}

Expand All @@ -104,6 +103,7 @@ fn default_asset() -> String {

pub async fn create(
State(state): State<Arc<AppState>>,
Extension(AuthenticatedMerchant(merchant_id)): Extension<AuthenticatedMerchant>,
headers: HeaderMap,
JsonBody(body): JsonBody<CreatePaymentRequest>,
) -> Result<(StatusCode, Json<Value>), AppError> {
Expand Down Expand Up @@ -135,8 +135,6 @@ pub async fn create(
}
}

let merchant_id = body.merchant_id.as_deref().unwrap_or("anonymous");

// An optional Idempotency-Key lets a client safely retry a create after a
// network blip without minting a duplicate intent. Keys are scoped per
// merchant; an empty header value is treated as absent.
Expand All @@ -150,7 +148,7 @@ pub async fn create(
// payment with 200 instead of creating a new one.
if let Some(key) = idempotency_key {
if let Some(existing_id) =
db::find_payment_id_by_idempotency_key(&state.pool, merchant_id, key).await?
db::find_payment_id_by_idempotency_key(&state.pool, &merchant_id, key).await?
{
if let Some(payment) = db::get_payment(&state.pool, &existing_id).await? {
return Ok((StatusCode::OK, Json(to_json(&payment))));
Expand All @@ -165,7 +163,7 @@ pub async fn create(
&state.pool,
db::NewPayment {
id: &id,
merchant_id,
merchant_id: &merchant_id,
destination_address: &state.config.gateway_public,
memo: &memo,
amount: &body.amount,
Expand All @@ -180,7 +178,7 @@ pub async fn create(
// `save_idempotency_key` returns the canonical id; return that payment so
// both retries converge on a single intent.
if let Some(key) = idempotency_key {
let canonical_id = db::save_idempotency_key(&state.pool, merchant_id, key, &id).await?;
let canonical_id = db::save_idempotency_key(&state.pool, &merchant_id, key, &id).await?;
if canonical_id != id {
if let Some(payment) = db::get_payment(&state.pool, &canonical_id).await? {
return Ok((StatusCode::OK, Json(to_json(&payment))));
Expand Down Expand Up @@ -219,6 +217,7 @@ const VALID_STATUSES: [&str; 4] = ["pending", "completed", "failed", "expired"];

pub async fn list(
State(state): State<Arc<AppState>>,
Extension(AuthenticatedMerchant(merchant_id)): Extension<AuthenticatedMerchant>,
Query(q): Query<ListQuery>,
) -> Result<Json<Value>, AppError> {
if let Some(s) = &q.status {
Expand All @@ -243,6 +242,7 @@ pub async fn list(

let payments = db::list_payments_keyset(
&state.pool,
&merchant_id,
q.status.as_deref(),
limit,
Some((&cursor_ts, &cursor_id)),
Expand All @@ -264,7 +264,7 @@ pub async fn list(
// Legacy offset pagination — kept for backward compatibility.
let offset = q.offset.unwrap_or(0).max(0);
let (payments, total) =
db::list_payments(&state.pool, q.status.as_deref(), limit, offset).await?;
db::list_payments(&state.pool, &merchant_id, q.status.as_deref(), limit, offset).await?;

// Provide next_cursor to ease migration to keyset pagination.
let next_cursor = payments.last().map(|p| encode_cursor(&p.created_at, &p.id));
Expand Down
Loading
Loading