DEV Community

Cover image for I Built a Private Rust Backend to Power 18 Developer Tools — Here's the Architecture
freerave
freerave

Posted on

I Built a Private Rust Backend to Power 18 Developer Tools — Here's the Architecture

A deep dive into building a production-grade Rust API server with multi-tier scheduling, HMAC auth, Lemon Squeezy webhooks, and 9 platform adapters — the engine behind DotSuite.

Most of my tools in the DotSuite ecosystem — VS Code extensions, CLI tools, Telegram bots — were islands.

Each one doing its own thing. No shared auth. No shared billing. No shared scheduling.

That changed when I started building DotShare v3, a VS Code extension that publishes code snippets to 9 platforms simultaneously. The moment I needed scheduling, quotas, and payments, one thing became clear: I need a real backend.

This article is about dotsuite-core — a private Rust server that is the beating heart of the DotSuite ecosystem. I won't open source it, but I'll walk you through the architecture, the decisions, and enough real code that you can build your own version.


Why Rust?

Not because it's trendy. Three very specific requirements drove the choice:

1. Exact-second scheduling.
The Max tier dispatches posts with millisecond precision. Node.js event loop jitter makes this unreliable. Tokio tasks with sleep(exact_ms) are deterministic.

2. Atomic quota enforcement.
Users can fire 5 concurrent requests at the same millisecond. A race condition means they publish more posts than their quota allows. Rust + MongoDB's atomic findOneAndUpdate solves this at the DB level — no mutex needed.

3. Long-running process.
Vercel serverless functions timeout after 10–60 seconds. My scheduler needs to run forever, with auto-restart on crash.


The Architecture

┌─────────────────────────┐      Bearer ds_prod_xxx
│  DotShare VS Code Ext   │ ─────────────────────────────────┐
└─────────────────────────┘                                  │
                                                             ▼
┌─────────────────────────┐   X-Internal-Secret   ┌─────────────────────┐
│  dotsuite-website       │ ─────────────────────▶ │   dotsuite-core     │
│  (Next.js on Vercel)    │                        │   (Rust on VPS)     │
└─────────────────────────┘                        └────────┬────────────┘
                                                            │
                                              ┌─────────────┴──────────┐
                                              │      MongoDB Atlas      │
                                              └────────────────────────┘
                                                            │
                                              Lemon Squeezy webhooks
Enter fullscreen mode Exit fullscreen mode

Three clients talk to one server:

  • VS Code extension → API keys (ds_prod_xxx)
  • Next.js website → internal shared secret (server-to-server)
  • Lemon Squeezy → HMAC-signed webhooks

The Stack

# Cargo.toml
axum              = { version = "0.7", features = ["macros", "ws"] }
tokio             = { version = "1", features = ["full"] }
mongodb           = { version = "3", features = ["tokio-runtime"] }
tower_governor    = { version = "0.4", features = ["axum"] }
serde             = { version = "1", features = ["derive"] }
jsonwebtoken      = "9"
hmac              = "0.12"
sha2              = "0.10"
constant_time_eq  = "0.3"
tokio-cron-scheduler = "0.10"
reqwest           = { version = "0.12", features = ["json", "rustls-tls", "multipart"] }
argon2            = "0.5"
futures-util      = "0.3"
Enter fullscreen mode Exit fullscreen mode

No unnecessary dependencies. Every crate earns its place.


File Structure

dotsuite-core/
├── Cargo.toml
├── .env.example
└── src/
    ├── main.rs              ← entry point, graceful shutdown
    ├── config.rs            ← typed env vars, validated at startup
    ├── state.rs             ← AppState shared across handlers
    ├── db.rs                ← MongoDB pool + indexes
    ├── errors.rs            ← AppError → HTTP responses
    ├── scheduler.rs         ← 4-tier cron + Look-Ahead + recovery
    │
    ├── auth/
    │   ├── mod.rs
    │   └── tokens.rs        ← HMAC API key generation (OsRng)
    │
    ├── middleware/
    │   ├── mod.rs
    │   ├── auth.rs          ← API key validation + blacklist check
    │   └── internal.rs      ← server-to-server secret validation
    │
    ├── models/
    │   ├── mod.rs
    │   └── user.rs          ← User, Tier, ApiKey, ScheduledPost, AuditLog
    │
    ├── payments/
    │   ├── mod.rs
    │   ├── signature.rs     ← HMAC-SHA256 webhook verification
    │   ├── webhook.rs       ← Lemon Squeezy event dispatcher
    │   ├── handlers.rs      ← subscription_created/cancelled/expired
    │   └── checkout.rs      ← checkout URL + customer portal
    │
    ├── platforms/
    │   ├── mod.rs           ← PlatformAdapter trait + concurrent dispatcher
    │   ├── x.rs             ← X (Twitter) — tweets, threads, 4 images
    │   ├── bluesky.rs       ← Bluesky — facets, blob upload, JIT compression
    │   ├── linkedin.rs      ← LinkedIn — 2-step media upload
    │   ├── telegram.rs      ← Telegram — text/photo/video/mediaGroup
    │   ├── facebook.rs      ← Facebook — Graph API v19
    │   ├── discord.rs       ← Discord — webhooks + embeds
    │   ├── reddit.rs        ← Reddit — r/ and u/ support
    │   ├── devto.rs         ← Dev.to — articles + cover image
    │   └── medium.rs        ← Medium — draft/publish/unlisted
    │
    └── routes/
        ├── mod.rs           ← router assembly
        ├── health.rs        ← /health + /ready
        ├── posts.rs         ← schedule + list + cancel
        ├── keys.rs          ← generate + list + revoke
        ├── billing.rs       ← checkout + portal + status
        ├── admin.rs         ← ban + unban + reset-quota
        └── internal.rs      ← Next.js → Rust server-to-server
Enter fullscreen mode Exit fullscreen mode

Part 1 — The Core Engine

Typed Config: Fail Fast, Not at Runtime

// src/config.rs
#[derive(Debug, Clone)]
pub struct AppConfig {
    pub mongodb_uri: String,
    pub api_token_secret: String,    // min 32 chars enforced at startup
    pub jwt_secret: String,
    pub internal_api_secret: String, // Next.js ↔ Rust shared secret
    pub lemon_webhook_secret: String,
    pub lemon_api_key: String,
    pub ls_variants: LemonVariants,
}

impl AppConfig {
    pub fn from_env() -> Result<Self> {
        Ok(Self {
            api_token_secret: required_min_len("API_TOKEN_SECRET", 32)?,
            // If the secret is weak, the server refuses to start.
            // Better to crash at boot than silently accept weak keys.
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

If the secret is weak, the server refuses to start. A misconfigured env var that crashes on boot is far better than one that silently accepts fake webhooks in production.


HMAC API Keys — The Right Way

Generated once, hashed in the DB, never stored in plaintext:

// src/auth/tokens.rs
use hmac::{Hmac, Mac};
use rand::rngs::OsRng;  // OsRng, NOT thread_rng — cryptographic quality
use sha2::Sha256;

const PREFIX: &str = "ds_prod_";
const RAW_BYTES: usize = 24; // 24 bytes → 48 hex chars

pub fn generate_api_key(secret: &str) -> (String, String, String) {
    let mut random_bytes = vec![0u8; RAW_BYTES];
    OsRng.fill_bytes(&mut random_bytes); // OS entropy, not pseudo-random

    let random_hex = hex::encode(&random_bytes);
    let plaintext = format!("{PREFIX}{random_hex}");
    // Result: "ds_prod_3f9a2c1b8e7d4a6f0c5b2e9d1a8f3c7b4e6d9a2c"

    let key_hash = hmac_sign(&plaintext, secret);
    // Store only the hash in MongoDB — never the plaintext

    let key_prefix = format!("{PREFIX}{}", &random_hex[..8]);
    // "ds_prod_3f9a2c1b" — shown to user for identification

    (plaintext, key_hash, key_prefix)
}

pub fn verify_api_key(presented: &str, stored_hash: &str, secret: &str) -> AppResult<()> {
    let computed = hmac_sign(presented, secret);

    // NEVER use == here — timing attack vulnerability.
    // constant_time_eq always takes the same time regardless of where strings differ.
    if !constant_time_eq::constant_time_eq(computed.as_bytes(), stored_hash.as_bytes()) {
        return Err(AppError::Unauthorized("Invalid API key".into()));
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

💡 Why OsRng over thread_rng?
thread_rng uses a CSPRNG seeded from the OS — technically fine. But OsRng draws directly from /dev/urandom on Linux with no intermediate state. For security tokens, no intermediate state is what you want.


The Race Condition Shield

This is the most subtle bug in quota systems:

  1. User has 10 posts remaining.
  2. They fire 5 simultaneous requests.
  3. All 5 read posts_used=290, all 5 see "under limit", all 5 publish.
  4. Result: 295 posts used — 5 over quota.

The fix is atomic at the DB level:

// ONE atomic operation — not read-then-write
let updated_user = users_col
    .find_one_and_update(
        doc! {
            "_id": user_id,
            // Only match if STILL under quota at the moment of the write
            "$expr": { "$and": [
                { "$lt": ["$posts_used", post_quota as i64] },
                { "$lt": ["$images_used", image_quota as i64] },
            ]}
        },
        doc! { "$inc": { "posts_used": 1, "images_used": has_media as i32 } },
    )
    .await?;

if updated_user.is_none() {
    // Either quota was hit, or a concurrent request just took the last slot
    return Err(AppError::Forbidden("Monthly quota exceeded".into()));
}
// If we get here, the increment already happened atomically.
// No mutex. No race. MongoDB's document-level locking handles it.
Enter fullscreen mode Exit fullscreen mode

Auth Middleware Flow

Every request to /v1/* goes through this:

// src/middleware/auth.rs
pub async fn require_api_key(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AppError> {
    let token = extract_bearer(&req)?;

    // 1. Format check — fast reject before DB hit
    if !token.starts_with("ds_prod_") || token.len() != 56 {
        return Err(AppError::Unauthorized("Invalid token format".into()));
    }

    // 2. Prefix lookup — indexed query, not full scan
    let prefix = &token[..16]; // "ds_prod_3f9a2c1b"
    let api_key = keys_col
        .find_one(doc! { "key_prefix": prefix, "is_active": true })
        .await?
        .ok_or_else(|| AppError::Unauthorized("Key not found".into()))?;

    // 3. Constant-time HMAC verification
    verify_api_key(&token, &api_key.key_hash, &state.config.api_token_secret)?;

    // 4. Update last_used_at (fire-and-forget, don't block the request)
    let _ = keys_col.update_one(
        doc! { "_id": key_id },
        doc! { "$set": { "last_used_at": bson::DateTime::now() } },
    ).await;

    // 5. Blacklist check — instant ban across all 18 tools
    if blacklist_col.find_one(doc! { "user_id": api_key.user_id }).await?.is_some() {
        return Err(AppError::Blacklisted);
    }

    // 6. Inject resolved User into request extensions
    req.extensions_mut().insert(user);
    Ok(next.run(req).await)
}
Enter fullscreen mode Exit fullscreen mode

Part 2 — The Money Pipeline

Lemon Squeezy Webhooks: Why Raw Bytes Matter

This is one of the most common mistakes in webhook implementations:

// ❌ WRONG — parses JSON first, then tries to verify
pub async fn webhook(Json(payload): Json<LemonPayload>, ...) -> impl IntoResponse {
    verify_signature(&headers, /* what bytes? */, &secret)?;
    // Problem: serde already consumed the body.
    // You can't get the original bytes back after JSON parsing.
    // Also: serde might reorder keys, strip whitespace, etc.
    // Your HMAC will never match.
}

// ✅ CORRECT — raw bytes first, parse after verification
pub async fn webhook(
    headers: HeaderMap,
    body: Bytes, // axum gives you raw bytes
) -> impl IntoResponse {
    // 1. Verify against the EXACT bytes Lemon Squeezy sent
    if let Err(e) = verify_signature(&headers, &body, &secret) {
        return StatusCode::UNAUTHORIZED;
    }
    // 2. NOW parse — signature is already confirmed
    let payload: LemonPayload = serde_json::from_slice(&body)?;
}
Enter fullscreen mode Exit fullscreen mode

The Signature Verification

// src/payments/signature.rs
pub fn verify_signature(headers: &HeaderMap, body: &Bytes, secret: &str) -> AppResult<()> {
    let presented = headers
        .get("X-Signature")
        .and_then(|v| v.to_str().ok())
        .ok_or_else(|| AppError::Unauthorized("Missing X-Signature".into()))?;

    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .map_err(|_| AppError::Internal(anyhow::anyhow!("HMAC init failed")))?;
    mac.update(body);
    let expected = hex::encode(mac.finalize().into_bytes());

    // Timing-safe: always compares all bytes, never short-circuits
    if !constant_time_eq(expected.as_bytes(), presented.as_bytes()) {
        return Err(AppError::Unauthorized("Signature mismatch".into()));
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Always Return 200 to Webhooks

// After signature passes, ALWAYS return 200 even if processing fails.
// Why? Lemon Squeezy retries on non-2xx responses.
// A 500 from your DB being slow = LS fires the webhook again = duplicate upgrade.

if let Err(e) = process_event(&state, &payload).await {
    tracing::error!(
        event = %payload.meta.event_name,
        error = %e,
        "Webhook processing failed — manual review needed"
    );
    // Log it, alert yourself, but DON'T return 500
}

StatusCode::OK // Always
Enter fullscreen mode Exit fullscreen mode

Subscription Lifecycle

// subscription_created  → upgrade tier + reset quota
// subscription_cancelled → record ends_at, DON'T downgrade yet
// subscription_expired  → downgrade to Free, clear subscription fields

pub async fn handle_subscription_expired(state: &AppState, payload: &LemonPayload) {
    users_col.update_one(
        doc! { "_id": user_id },
        doc! { "$set": {
            "tier":                 "free",
            "ls_subscription_id":   bson::Bson::Null,
            "subscription_ends_at": bson::Bson::Null,
        }},
    ).await?;
    // User loses paid access on the ACTUAL expiry date, not cancellation date.
    // They paid for the full period — this is the fair thing to do.
}
Enter fullscreen mode Exit fullscreen mode

Part 3 — Multi-Tier Scheduler

The Tier System

pub enum Tier { Free, Basic, Pro, Max }

impl Tier {
    pub fn post_quota(&self) -> u32 {
        match self {
            Tier::Free  => 100,
            Tier::Basic => 300,
            Tier::Pro   => u32::MAX, // unlimited
            Tier::Max   => u32::MAX,
        }
    }

    pub fn image_quota(&self) -> u32 {
        match self {
            Tier::Free => 10,        // sub-limit: 10 image posts/month
            _          => u32::MAX,  // unlimited for paid tiers
        }
    }

    pub fn scheduler_interval_minutes(&self) -> u32 {
        match self {
            Tier::Free  => 60,
            Tier::Basic => 30,
            Tier::Pro   => 15,
            Tier::Max   => 0, // instant — Look-Ahead architecture
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Look-Ahead + Sleep Pattern (Max Tier)

This is the pattern Buffer and Hootsuite use internally. Not polling every second (kills your DB), not trusting in-memory only (crashes lose data):

// Max tier scheduler — runs every 60 seconds
async fn dispatch_max_lookahead(db: &DbPool) {
    let now = Utc::now();
    let window_end = now + chrono::Duration::seconds(60);

    // ONE DB query per minute — not one per second
    let posts = find_pending_posts(db, Tier::Max, now, window_end).await;

    for post in posts {
        // Mark as Dispatched FIRST — this is the crash safety mechanism
        mark_dispatched(&post.id, db).await;

        let delay_ms = (post.scheduled_at - Utc::now())
            .num_milliseconds()
            .max(0) as u64;

        let db_clone = db.clone();
        tokio::spawn(async move {
            // Sleep for exact remaining milliseconds
            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
            publish_post(&db_clone, post).await;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The Dispatched status is crash safety:

Pending → Dispatched → Published
                     ↘ Failed
Enter fullscreen mode Exit fullscreen mode

On server restart, recovery runs immediately:

async fn recover_dispatched_posts(db: &DbPool) {
    // Any post still Dispatched = server crashed mid-flight
    // Re-spawn them immediately
    let stuck_posts = find_dispatched_posts(db).await;
    for post in stuck_posts {
        let delay_ms = (post.scheduled_at - Utc::now())
            .num_milliseconds().max(0) as u64;
        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
            publish_post(&db, post).await;
        });
    }
    // Worst case: 60 seconds of scheduling precision lost after a crash.
    // Not zero posts.
}
Enter fullscreen mode Exit fullscreen mode

Platform Adapter Pattern

All 9 platform adapters implement one trait:

#[async_trait]
pub trait PlatformAdapter: Send + Sync {
    fn platform(&self) -> Platform;
    async fn publish(&self, post: &ScheduledPost, token: &str) -> AdapterResult;
}

// Dispatch to all platforms CONCURRENTLY — not sequentially
pub async fn dispatch_all(post: &ScheduledPost, get_token: impl Fn(&Platform) -> Option<String>) {
    let mut handles = vec![];

    for adapter in get_active_adapters(post) {
        let token = get_token(&adapter.platform()).unwrap_or_default();
        let post_clone = post.clone();

        // tokio::spawn = true parallelism
        // All 9 platforms publish simultaneously, not one after another
        handles.push(tokio::spawn(async move {
            adapter.publish(&post_clone, &token).await
        }));
    }

    for handle in handles {
        match handle.await {
            Ok(Ok(result)) => log_success(result),
            Ok(Err(e))     => log_platform_error(e),
            Err(panic)     => log_adapter_panic(panic),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Internal Route (Next.js ↔ Rust)

// Next.js calls this — never the VS Code extension
// POST /internal/keys/generate
// GET  /internal/keys/:user_id
// DEL  /internal/keys/:prefix

pub async fn require_internal_secret(
    State(state): State<AppState>,
    req: Request,
    next: Next,
) -> Result<Response, AppError> {
    let secret = req.headers()
        .get("X-Internal-Secret")
        .and_then(|v| v.to_str().ok())
        .ok_or(AppError::Unauthorized("Missing internal secret".into()))?;

    // Same constant_time_eq pattern — even internal secrets get timing protection
    if !constant_time_eq(secret.as_bytes(), state.config.internal_api_secret.as_bytes()) {
        return Err(AppError::Unauthorized("Invalid internal secret".into()));
    }
    Ok(next.run(req).await)
}
Enter fullscreen mode Exit fullscreen mode

Complete Endpoint Map

# Public
GET  /health                          liveness probe
GET  /ready                           readiness (DB ping)
POST /v1/webhooks/lemon               LS webhook (HMAC-verified)

# API Key protected (VS Code extension)
GET  /v1/ping                         auth test
POST /v1/posts/schedule               schedule a post
GET  /v1/posts                        list posts (paginated)
DEL  /v1/posts/:id                    cancel a pending post
POST /v1/keys/generate                generate new API key
GET  /v1/keys                         list active keys
DEL  /v1/keys/:prefix                 revoke a key
POST /v1/billing/checkout             get LS checkout URL
GET  /v1/billing/portal               get LS customer portal URL
GET  /v1/billing/status               current tier + quota usage

# Admin (API key + admin role)
POST /v1/admin/blacklist              ban user (instant, all 18 tools)
DEL  /v1/admin/blacklist/:user_id     unban
POST /v1/admin/reset-quota/:user_id   manual quota reset
GET  /v1/admin/users/:user_id         user info + stats

# Internal (Next.js server-to-server only)
POST /internal/keys/generate          generate key for website user
GET  /internal/keys/:user_id          list keys for website user
DEL  /internal/keys/:prefix           revoke key for website user
Enter fullscreen mode Exit fullscreen mode

What's Next (Part 4)

The server is feature-complete for the current scope. Still in progress:

  • OAuth token storage — storing platform OAuth tokens per user so the scheduler can call the 9 adapters with real credentials
  • WebSocket push — real-time feedback to the VS Code extension when a post publishes (Pro/Max tiers)
  • Referral engine — every 5 referrals = 1 free Pro month, enforced in webhook handlers

6 Decisions I'd Make Again

1. Fail at startup, not at runtime.
Validate all config at boot. A misconfigured WEBHOOK_SECRET that crashes on the first payment is better than silently accepting fake webhooks.

2. Atomic DB operations over application-level locks.
MongoDB's findOneAndUpdate with a conditional filter is more reliable than Mutex for quota enforcement. The DB is already your source of truth.

3. OsRng for security tokens, constant_time_eq for comparisons.
Never thread_rng for secrets. Never == for HMAC comparison.

4. Raw bytes before JSON for webhooks.
Always read Bytes before parsing JSON when you need to verify a signature.

5. Dispatched status as crash safety.
Any in-memory operation that can't complete atomically needs a DB flag. The scheduler tick is: mark → spawn → publish. If you crash between mark and publish, recovery finds the flag.

6. The Look-Ahead + Sleep pattern scales.
One DB query per minute + OS-level sleep timers gives you exact-second precision without polling. It's what production schedulers use.


The server is private, but every pattern here is battle-tested and applicable to any Rust + MongoDB + Axum stack.

If you have questions about any specific part, drop them in the comments.


FreeRave — building DotSuite: a suite of developer productivity tools. Follow for more deep dives into production Rust backends.

Top comments (0)