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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ JWT_SECRET=
BIND_ADDR=0.0.0.0:8080
RUST_LOG=info,octo=debug

# Base URL of the hosted checkout frontend (Next.js), used to build the `url` field on payment
# links (e.g. https://app.octo.dev/pay/<slug>). No trailing slash.
PUBLIC_APP_URL=http://localhost:3000

# --- Ingest worker ---
# How often the deposit ingest supervisor polls Horizon for all wallets, and the page size.
INGEST_INTERVAL_SECS=5
Expand Down
10 changes: 10 additions & 0 deletions bin/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ async fn main() -> Result<()> {
cfg.network,
cfg.horizon_url.clone(),
cfg.friendbot_url.clone(),
cfg.public_app_url.clone(),
resilience.retry_policy(),
resilience.circuit_breaker(),
)
Expand Down Expand Up @@ -115,6 +116,9 @@ struct Config {
network: StellarNetwork,
horizon_url: String,
friendbot_url: Option<String>,
/// Base URL of the hosted checkout frontend (e.g. `https://app.octo.dev`), used to build the
/// `url` field on payment-link responses. Defaults to the local frontend dev server.
public_app_url: String,
master_key: [u8; 32],
/// Optional next master key for zero-downtime rotation. Present only during the rotation
/// window while `octo-migrate-keys` is backfilling. When set, the server uses this key as
Expand Down Expand Up @@ -150,6 +154,11 @@ impl Config {
.unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string());
let friendbot_url = std::env::var("FRIENDBOT_URL").ok();

let public_app_url = std::env::var("PUBLIC_APP_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string())
.trim_end_matches('/')
.to_string();

let master_key_b64 = std::env::var("MASTER_KEY").context("MASTER_KEY is required")?;
let master_key = AppState::decode_master_key(&master_key_b64)
.map_err(|_| anyhow::anyhow!("MASTER_KEY must be base64-encoded 32 bytes"))?;
Expand Down Expand Up @@ -189,6 +198,7 @@ impl Config {
network,
horizon_url,
friendbot_url,
public_app_url,
master_key,
master_key_next,
jwt_secret,
Expand Down
45 changes: 35 additions & 10 deletions crates/api/src/routes/payment_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,32 @@ const USDC_ASSET_CODE: &str = "USDC";
/// any asset landing at the right address.
const USDC_TESTNET_ISSUER: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";

/// Full hosted checkout URL for a payment link's slug.
fn checkout_url(state: &AppState, slug: &str) -> String {
format!("{}/pay/{}", state.public_app_url(), slug)
}

#[derive(Debug, Serialize)]
pub struct PaymentLinkView {
pub id: Uuid,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub image_url: Option<String>,
pub redirect_url: Option<String>,
pub amount_usdc_stroops: Option<i64>,
pub active: bool,
pub collected_usdc_stroops: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
pub url: String,
}

#[derive(Debug, Default, Deserialize)]
pub struct CreatePaymentLinkRequest {
pub name: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub redirect_url: Option<String>,
pub amount_usdc_stroops: Option<i64>,
}

Expand Down Expand Up @@ -78,20 +86,24 @@ pub async fn create_payment_link(
name: &name,
description: req.description.as_deref(),
image_url: req.image_url.as_deref(),
redirect_url: req.redirect_url.as_deref(),
amount_usdc_stroops: req.amount_usdc_stroops,
})
.await?;

let url = checkout_url(&state, &link.slug);
let (code, json) = Envelope::created(PaymentLinkView {
id: link.id,
slug: link.slug,
name: link.name,
description: link.description,
image_url: link.image_url,
redirect_url: link.redirect_url,
amount_usdc_stroops: link.amount_usdc_stroops,
active: link.active,
collected_usdc_stroops: 0,
created_at: link.created_at,
url,
});
Ok((code, json))
}
Expand Down Expand Up @@ -131,16 +143,21 @@ pub async fn list_payment_links(

let views = items
.into_iter()
.map(|l| PaymentLinkView {
id: l.id,
slug: l.slug,
name: l.name,
description: l.description,
image_url: l.image_url,
amount_usdc_stroops: l.amount_usdc_stroops,
active: l.active,
collected_usdc_stroops: totals.get(&l.id).copied().unwrap_or(0),
created_at: l.created_at,
.map(|l| {
let url = checkout_url(&state, &l.slug);
PaymentLinkView {
id: l.id,
slug: l.slug,
name: l.name,
description: l.description,
image_url: l.image_url,
redirect_url: l.redirect_url,
amount_usdc_stroops: l.amount_usdc_stroops,
active: l.active,
collected_usdc_stroops: totals.get(&l.id).copied().unwrap_or(0),
created_at: l.created_at,
url,
}
})
.collect();

Expand All @@ -165,16 +182,19 @@ pub async fn get_payment_link(
authorize_wallet(&headers, &state, wallet_id).await?;
let link = state.store().get_payment_link(wallet_id, link_id).await?;
let collected = state.store().sum_payment_link_collected(link.id).await?;
let url = checkout_url(&state, &link.slug);
Ok(Envelope::ok(PaymentLinkView {
id: link.id,
slug: link.slug,
name: link.name,
description: link.description,
image_url: link.image_url,
redirect_url: link.redirect_url,
amount_usdc_stroops: link.amount_usdc_stroops,
active: link.active,
collected_usdc_stroops: collected,
created_at: link.created_at,
url,
}))
}

Expand Down Expand Up @@ -268,16 +288,19 @@ pub async fn set_payment_link_active(
.set_payment_link_active(wallet_id, link_id, active)
.await?;
let collected = state.store().sum_payment_link_collected(link.id).await?;
let url = checkout_url(&state, &link.slug);
Ok(Envelope::ok(PaymentLinkView {
id: link.id,
slug: link.slug,
name: link.name,
description: link.description,
image_url: link.image_url,
redirect_url: link.redirect_url,
amount_usdc_stroops: link.amount_usdc_stroops,
active: link.active,
collected_usdc_stroops: collected,
created_at: link.created_at,
url,
}))
}

Expand All @@ -286,6 +309,7 @@ pub struct PublicPaymentLinkView {
pub name: String,
pub description: Option<String>,
pub image_url: Option<String>,
pub redirect_url: Option<String>,
pub amount_usdc_stroops: Option<i64>,
pub deposit_address: String,
pub asset_code: String,
Expand Down Expand Up @@ -337,6 +361,7 @@ pub async fn get_public_payment_link(
name: link.name,
description: link.description,
image_url: link.image_url,
redirect_url: link.redirect_url,
amount_usdc_stroops: link.amount_usdc_stroops,
deposit_address: address.muxed_address,
asset_code: USDC_ASSET_CODE.into(),
Expand Down
14 changes: 14 additions & 0 deletions crates/api/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ struct Inner {
horizon: Horizon,
horizon_url: String,
friendbot_url: Option<String>,
/// Base URL of the hosted checkout frontend, used to build the `url` field on payment-link
/// responses (e.g. `https://app.octo.dev/pay/<slug>`). No trailing slash.
public_app_url: String,
/// HMAC secret for signing dashboard auth JWTs.
jwt_secret: Vec<u8>,
/// Fires signed webhooks (e.g. `transaction.sponsored`) to registered endpoints.
Expand Down Expand Up @@ -86,19 +89,22 @@ impl AppState {
network,
horizon_url,
friendbot_url,
"http://localhost:3000".to_string(),
secret,
octo_resilience::RetryPolicy::default(),
octo_resilience::CircuitBreaker::new(5, std::time::Duration::from_secs(30)),
)
}

/// Build state with explicit resilience configuration (used by `bin/server`).
#[allow(clippy::too_many_arguments)]
pub fn new_with_resilience(
store: Store,
master_key: [u8; MASTER_KEY_LEN],
network: StellarNetwork,
horizon_url: String,
friendbot_url: Option<String>,
public_app_url: String,
retry: octo_resilience::RetryPolicy,
circuit: octo_resilience::CircuitBreaker,
) -> Self {
Expand All @@ -111,6 +117,7 @@ impl AppState {
network,
horizon_url,
friendbot_url,
public_app_url,
secret,
retry,
circuit,
Expand Down Expand Up @@ -142,6 +149,7 @@ impl AppState {
network: StellarNetwork,
horizon_url: String,
friendbot_url: Option<String>,
public_app_url: String,
jwt_secret: Vec<u8>,
retry: RetryPolicy,
circuit: CircuitBreaker,
Expand All @@ -159,6 +167,7 @@ impl AppState {
horizon,
horizon_url,
friendbot_url,
public_app_url,
jwt_secret,
webhooks,
}),
Expand Down Expand Up @@ -234,4 +243,9 @@ impl AppState {
pub fn friendbot_url(&self) -> Option<&str> {
self.inner.friendbot_url.as_deref()
}

/// Base URL of the hosted checkout frontend (no trailing slash).
pub fn public_app_url(&self) -> &str {
&self.inner.public_app_url
}
}
56 changes: 56 additions & 0 deletions crates/api/tests/api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2053,6 +2053,62 @@ async fn payment_link_management_requires_wallet_ownership() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn payment_link_response_includes_checkout_url_and_redirect_url() {
let Some(state) = test_state().await else {
eprintln!("SKIPPED: set DATABASE_URL to run integration tests");
return;
};
let app = build_router(state);
let token = auth_token(&app).await;
let wallet_id = create_wallet_for(&app, &token).await;

let uri = format!("/v1/wallets/{wallet_id}/payment-links");
let resp = app
.clone()
.oneshot(post_json_auth(
&uri,
r#"{"name":"Support","redirect_url":"https://merchant.example/thank-you"}"#,
&token,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let created = body_json(resp).await;
let slug = created["data"]["slug"].as_str().unwrap().to_string();
let url = created["data"]["url"].as_str().unwrap();
assert!(
url.ends_with(&format!("/pay/{slug}")),
"url must be a real hosted checkout link ending in /pay/<slug>, got {url}"
);
assert_eq!(
created["data"]["redirect_url"],
"https://merchant.example/thank-you"
);

// GET and the public route must echo the same fields.
let link_id = created["data"]["id"].as_str().unwrap();
let get_uri = format!("/v1/wallets/{wallet_id}/payment-links/{link_id}");
let resp = app
.clone()
.oneshot(get_auth(&get_uri, &token))
.await
.unwrap();
let fetched = body_json(resp).await;
assert_eq!(fetched["data"]["url"], url);
assert_eq!(
fetched["data"]["redirect_url"],
"https://merchant.example/thank-you"
);

let resp = app.oneshot(get(&format!("/v1/pay/{slug}"))).await.unwrap();
let public = body_json(resp).await;
assert_eq!(
public["data"]["redirect_url"],
"https://merchant.example/thank-you"
);
}

#[tokio::test]
async fn payment_link_intent_rejects_flexible_amount_without_one() {
let Some(state) = test_state().await else {
Expand Down
5 changes: 5 additions & 0 deletions crates/store/migrations/0017_payment_link_redirect_url.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Optional post-checkout redirect: developer-supplied via their own API key at link-creation
-- time, used only to send the payer's own browser back to the merchant's site after
-- confirmation. Not attacker-controlled and never fetched server-side, so no SSRF/allowlist
-- validation is needed — same bare-passthrough treatment as image_url.
ALTER TABLE payment_links ADD COLUMN redirect_url TEXT;
5 changes: 3 additions & 2 deletions crates/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,8 +1137,8 @@ impl Store {
let row = sqlx::query_as::<_, PaymentLink>(
r#"
INSERT INTO payment_links
(wallet_id, address_id, slug, name, description, image_url, amount_usdc_stroops)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(wallet_id, address_id, slug, name, description, image_url, redirect_url, amount_usdc_stroops)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
"#,
)
Expand All @@ -1148,6 +1148,7 @@ impl Store {
.bind(link.name)
.bind(link.description)
.bind(link.image_url)
.bind(link.redirect_url)
.bind(link.amount_usdc_stroops)
.fetch_one(&self.pool)
.await
Expand Down
2 changes: 2 additions & 0 deletions crates/store/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ pub struct PaymentLink {
pub name: String,
pub description: Option<String>,
pub image_url: Option<String>,
pub redirect_url: Option<String>,
pub amount_usdc_stroops: Option<i64>,
pub active: bool,
pub created_at: DateTime<Utc>,
Expand All @@ -274,6 +275,7 @@ pub struct NewPaymentLink<'a> {
pub name: &'a str,
pub description: Option<&'a str>,
pub image_url: Option<&'a str>,
pub redirect_url: Option<&'a str>,
pub amount_usdc_stroops: Option<i64>,
}

Expand Down
7 changes: 4 additions & 3 deletions crates/store/tests/store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ async fn payment_link_lifecycle_intent_confirm_and_sum() {
name: "Support octo",
description: Some("donations"),
image_url: None,
redirect_url: None,
amount_usdc_stroops: None,
})
.await
Expand Down Expand Up @@ -687,7 +688,7 @@ async fn migrate_applies_exactly_the_expected_version_set() {
.expect("query _sqlx_migrations");
versions.sort_unstable();

// One version per file under crates/store/migrations/, 0001_init.sql .. 0016.
// One version per file under crates/store/migrations/, 0001_init.sql .. 0017.
//
// NOTE: this version number is a repeat offender — five migrations have now landed with a
// colliding 0008 at one point or another (scheme_version, token_denylist,
Expand All @@ -697,8 +698,8 @@ async fn migrate_applies_exactly_the_expected_version_set() {
// every version explicitly rather than just checking a count.
assert_eq!(
versions,
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"expected exactly the sixteen known migrations to be recorded as applied"
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
"expected exactly the seventeen known migrations to be recorded as applied"
);
}

Expand Down
Loading
Loading