App Communication Guide
App Communication Guide
A reference for connecting a Desktop App, Website, and Admin Dashboard through a shared backend. This guide is technology-agnostic — it covers the architecture patterns and shows where different databases, auth providers, payment systems, and hosting options plug in.
Table of Contents
- The Big Picture
- Architecture Overview
- Choosing a Database
- Authentication Providers
- Payment Systems
- Webhooks (Local and Online)
- Environment Variables Overview
- User Authentication Flow
- AI Credits System
- Desktop App <-> Backend Communication
- Website (Download Page)
- Owner Dashboard
- Real-time Sync
- Offline Support
- Auto-Updates for Desktop
- Error Handling Strategy
- Deployment Topology
- Security Considerations
- Implementation Checklist
The Big Picture
+------------------------------------------------------------------+
| YOUR ECOSYSTEM |
+------------------------------------------------------------------+
| |
| Desktop App Website Dashboard |
| (The main tool) (The store) (Your HQ) |
| |
| | | | |
| +---------------------+---------------------+ |
| | |
| +----------+----------+ |
| | Central Backend | |
| | (The brain) | |
| +----------+----------+ |
| | |
| +----------+----------+ |
| | Database(s) | |
| | (The memory) | |
| +---------------------+ |
+------------------------------------------------------------------+
| Component |
Purpose |
Who Uses It |
| Desktop App |
The actual product users download and use |
Your customers |
| Website |
Marketing site + download page + user portal |
Potential and existing customers |
| Dashboard |
Admin panel for you to manage everything |
You (the owner) |
| Backend |
API server that connects everything |
All three apps |
| Database(s) |
Stores users, credits, analytics, content |
Backend |
Architecture Overview
Choosing a Database
There is no single "best" database. Most production systems use 2-3 types together. Choose based on what your data looks like and how you query it.
Database Types
| Type |
Best For |
Examples |
When to Use |
| Relational (SQL) |
Structured data, transactions, joins |
PostgreSQL, MySQL, SQLite, CockroachDB |
User accounts, purchases, anything with relationships |
| Document |
Flexible schemas, nested data |
MongoDB, Firebase Firestore, CouchDB |
Content, user profiles, settings, JSON-heavy data |
| Key-Value / Cache |
Fast reads, sessions, ephemeral data |
Redis, Memcached, DynamoDB |
Session tokens, rate limiting, real-time counters, caching |
| Graph |
Relationships are the data |
Neo4j, ArangoDB, Amazon Neptune |
Social networks, recommendation engines, knowledge graphs |
| Vector |
Similarity search, embeddings |
Pinecone, Weaviate, Qdrant, pgvector, ChromaDB |
AI/ML features, semantic search, RAG pipelines |
| Time-Series |
Timestamped data, metrics |
InfluxDB, TimescaleDB, QuestDB |
Analytics, monitoring, IoT data, usage tracking |
| Search |
Full-text search, faceted filtering |
Elasticsearch, Meilisearch, Algolia, Typesense |
Product search, log analysis, autocomplete |
| BaaS (Backend-as-a-Service) |
All-in-one (DB + Auth + Realtime) |
Supabase, Firebase, Appwrite, Nhost, PocketBase |
Rapid prototyping, or when you want auth + DB + realtime bundled |
ORM / Client Options (for Relational)
| ORM / Query Builder |
Language |
Notes |
| Prisma |
TypeScript |
Type-safe, schema-first, great DX, generates migrations |
| Drizzle |
TypeScript |
Lightweight, SQL-like syntax, no code generation |
| TypeORM |
TypeScript |
Decorator-based, supports many databases |
| Knex |
JavaScript/TS |
Query builder (not full ORM), very flexible |
| Sequelize |
JavaScript/TS |
Mature, feature-rich, heavier |
| SQLAlchemy |
Python |
The standard for Python backends |
| Django ORM |
Python |
Batteries-included with Django |
Common Combinations
| Use Case |
Primary DB |
Cache |
Search |
Vector |
| Simple SaaS |
PostgreSQL |
Redis |
— |
— |
| AI-powered app |
PostgreSQL |
Redis |
— |
pgvector or Pinecone |
| Content platform |
MongoDB |
Redis |
Meilisearch |
— |
| Analytics-heavy |
PostgreSQL |
Redis |
— |
— + TimescaleDB |
| Rapid prototype |
Supabase (Postgres) |
built-in |
— |
— |
| Social / graph data |
PostgreSQL |
Redis |
Elasticsearch |
Neo4j |
Local vs. Managed
| Approach |
Pros |
Cons |
Examples |
| Self-hosted |
Full control, no vendor lock-in, cheaper at scale |
You manage backups, scaling, security |
PostgreSQL on a VPS, Redis on Docker |
| Managed cloud |
Zero-ops, automatic backups, scaling |
Cost, vendor lock-in |
Supabase, PlanetScale, Neon, Railway, AWS RDS, MongoDB Atlas |
| Embedded / Local |
No network, instant, great for desktop |
Single-machine, no cloud sync |
SQLite, LevelDB, electron-store |
Authentication Providers
Options Landscape
| Provider |
Type |
Best For |
Notes |
| Clerk |
Managed |
Polished UI, fast integration |
Pre-built components, expensive at scale |
| Auth.js (NextAuth) |
Self-hosted |
Next.js apps |
Free, flexible, many adapters, you manage the session store |
| Supabase Auth |
Managed |
Already using Supabase |
Bundled with Supabase, Row Level Security integration |
| Firebase Auth |
Managed |
Mobile + web |
Google ecosystem, generous free tier |
| Lucia |
Self-hosted library |
Full control |
Lightweight, bring your own DB, no vendor lock-in |
| Keycloak |
Self-hosted |
Enterprise, SSO/SAML |
Heavy but powerful, open source |
| Auth0 / Okta |
Managed |
Enterprise, many integrations |
Mature, expensive, feature-rich |
| Passport.js |
Library |
Custom flows |
Just a middleware — you build everything around it |
| Custom JWT |
DIY |
Full control |
Most work, most flexibility, highest risk of mistakes |
| WorkOS |
Managed |
B2B / enterprise SSO |
SAML/SCIM out of the box |
| Hanko |
Managed / Self-hosted |
Passkeys |
Passwordless-first, modern |
Choosing
| Scenario |
Recommended |
| Shipping fast, budget exists |
Clerk or Supabase Auth |
| Next.js app, cost-sensitive |
Auth.js (NextAuth) |
| Full control, no vendor lock-in |
Lucia or custom JWT |
| Enterprise / SSO requirement |
Keycloak, Auth0, or WorkOS |
| Already on Firebase |
Firebase Auth |
| Desktop + web, same auth |
Any provider with JWT — verify tokens on your backend |
Generic Auth Middleware Pattern
Regardless of which provider you choose, the backend middleware follows the same shape:
// backend/middleware/auth.ts
// The provider-specific part: how you verify a token
// - Clerk: clerk.sessions.verifySession(token)
// - Supabase: supabase.auth.getUser(token)
// - Firebase: admin.auth().verifyIdToken(token)
// - Auth.js: decode JWT with your AUTH_SECRET
// - Custom JWT: jwt.verify(token, SECRET)
async function verifyToken(token: string): Promise<DecodedUser | null> {
// Your provider-specific verification here
}
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const user = await verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({ error: 'Authentication failed' });
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
requireAuth(req, res, () => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
});
}
Payment Systems
Options Landscape
| Provider |
Type |
Best For |
Fees (approx.) |
| Stripe |
Full platform |
SaaS, subscriptions, one-time payments |
2.9% + $0.30/tx |
| LemonSqueezy |
Merchant of Record |
Indie devs, digital products, handles tax |
5% + $0.50/tx |
| Paddle |
Merchant of Record |
SaaS, global tax compliance |
5% + $0.50/tx |
| Gumroad |
Marketplace |
Creators, digital products |
10% |
| PayPal |
Payment processor |
International, buyer trust |
2.9% + $0.30/tx |
| Square |
Payment processor |
In-person + online |
2.6% + $0.10/tx |
| Braintree |
Payment processor |
PayPal-owned, flexible |
2.59% + $0.49/tx |
| Razorpay |
Payment processor |
India-focused |
2% per tx |
| Mollie |
Payment processor |
Europe-focused |
varies by method |
| Coinbase Commerce |
Crypto |
Accepting cryptocurrency |
1% |
| Open-source (Kill Bill, Lago) |
Self-hosted billing |
Full control, complex billing |
Free (you host) |
Merchant of Record vs. Payment Processor
| Aspect |
Payment Processor (Stripe, PayPal) |
Merchant of Record (LemonSqueezy, Paddle) |
| Tax handling |
You calculate and file taxes |
They handle all sales tax/VAT globally |
| Legal entity |
You are the seller |
They are the seller (reselling for you) |
| Refunds |
You handle disputes |
They handle disputes |
| Payout |
Direct to your bank |
They pay you (minus fees) |
| Control |
Full control |
Less control, simpler |
| Best for |
Established business, custom flows |
Solo devs, indie products, "just works" |
Choosing
| Scenario |
Recommended |
| SaaS with subscriptions |
Stripe |
| Digital product, don't want to deal with tax |
LemonSqueezy or Paddle |
| Need PayPal as an option |
Stripe + PayPal, or Braintree |
| Selling to India |
Razorpay |
| Selling to Europe |
Mollie or Stripe |
| Open-source / self-hosted billing |
Kill Bill or Lago |
Generic Payment Flow
The pattern is the same regardless of provider:
// 1. Client requests a checkout session
// POST /api/create-checkout
router.post('/create-checkout', requireAuth, async (req, res) => {
const { packageId } = req.body;
const userId = req.user.id;
const pkg = CREDIT_PACKAGES[packageId];
// Create checkout session with your provider:
// - Stripe: stripe.checkout.sessions.create(...)
// - LemonSqueezy: lemonsqueezy.createCheckout(...)
// - Paddle: paddle.transactions.create(...)
const session = await paymentProvider.createCheckout({
amount: pkg.price,
currency: 'usd',
metadata: { userId, packageId, credits: pkg.credits },
successUrl: `${WEBSITE_URL}/purchase/success`,
cancelUrl: `${WEBSITE_URL}/purchase/cancelled`,
});
// Record pending purchase in your database
await db.purchases.create({
userId,
amount: pkg.price,
credits: pkg.credits,
providerSessionId: session.id,
status: 'pending',
});
res.json({ checkoutUrl: session.url });
});
// 2. Provider sends a webhook when payment completes
// POST /webhooks/payment
router.post('/webhooks/payment', async (req, res) => {
// Verify webhook signature (CRITICAL — never skip this)
const event = verifyWebhookSignature(req);
if (event.type === 'payment.completed') {
const { userId, credits } = event.metadata;
await db.transaction(async (tx) => {
// Mark purchase as completed
await tx.purchases.update({
where: { providerSessionId: event.sessionId },
data: { status: 'completed' },
});
// Add credits to user
await tx.users.incrementCredits(userId, parseInt(credits));
});
// Notify connected desktop app via WebSocket
await notifyUser(userId, { type: 'credits_added', credits: parseInt(credits) });
}
if (event.type === 'payment.refunded') {
const { userId, credits } = event.metadata;
await db.transaction(async (tx) => {
await tx.purchases.update({
where: { providerSessionId: event.sessionId },
data: { status: 'refunded' },
});
// Remove credits (clamp to zero)
const user = await tx.users.findById(userId);
const newBalance = Math.max(0, user.credits - parseInt(credits));
await tx.users.setCredits(userId, newBalance);
});
await notifyUser(userId, { type: 'credits_removed', reason: 'refund' });
}
res.json({ received: true });
});
Webhooks (Local and Online)
Webhooks are HTTP callbacks — when something happens in one system, it sends a POST request to your server.
How Webhooks Work
Common Webhook Sources
| Source |
Events You Receive |
What You Do With Them |
| Stripe / Paddle / LemonSqueezy |
payment.completed, subscription.updated, charge.refunded |
Update credits, change plan, revoke access |
| Clerk / Auth0 |
user.created, user.deleted, session.ended |
Create DB record, clean up data, audit log |
| GitHub |
push, pull_request, release |
Trigger builds, notify team |
| SendGrid / Resend |
email.delivered, email.bounced |
Update delivery status, handle bounces |
| Twilio |
message.received, call.completed |
Process inbound messages |
| Your own services |
Custom events |
Decouple internal microservices |
Webhook Security
Always verify webhook signatures. Every serious provider signs their payloads:
// Generic webhook handler pattern
router.post('/webhooks/:provider', express.raw({ type: 'application/json' }), async (req, res) => {
const provider = req.params.provider;
const signature = req.headers['x-signature'] ||
req.headers['stripe-signature'] ||
req.headers['x-webhook-signature'];
try {
// Each provider has its own verification method:
// - Stripe: stripe.webhooks.constructEvent(body, sig, secret)
// - Clerk: svix.verify(body, headers)
// - LemonSqueezy: verify HMAC-SHA256
// - Generic: crypto.timingSafeEqual(computedHmac, signature)
const event = await verifyWebhook(provider, req.body, signature);
// Process the event
await processWebhookEvent(provider, event);
res.json({ received: true });
} catch (error) {
console.error(`Webhook verification failed for ${provider}:`, error);
res.status(400).json({ error: 'Invalid signature' });
}
});
Local Development with Webhooks
External services can not reach localhost. Solutions:
| Tool |
How It Works |
Cost |
| Stripe CLI |
stripe listen --forward-to localhost:3001/webhooks/stripe |
Free |
| ngrok |
Creates a public tunnel to your localhost |
Free tier available |
| Cloudflare Tunnel |
cloudflared tunnel to expose local services |
Free |
| localtunnel |
lt --port 3001 for quick testing |
Free |
| Webhook.site |
Inspect payloads in browser, no code needed |
Free |
| smee.io |
GitHub's webhook proxy for development |
Free |
Internal Webhooks (Service-to-Service)
For your own microservices, you can use the same pattern or alternatives:
| Approach |
Best For |
Notes |
| HTTP webhooks |
Simple, decoupled services |
Same as external webhooks, just between your own services |
| Message queues (RabbitMQ, SQS, BullMQ) |
Reliable delivery, retry logic |
Better for high-volume or critical events |
| Event streaming (Kafka, Redis Streams) |
Real-time, ordered events, multiple consumers |
More infrastructure, more power |
| Pub/Sub (Redis, Google Pub/Sub, SNS) |
Fan-out to many subscribers |
Simple, fire-and-forget |
| Database triggers |
React to data changes |
Supabase Realtime, Postgres LISTEN/NOTIFY |
Environment Variables Overview
Each app needs different secrets. Never share backend secrets with frontend apps.
| Variable |
Backend |
Website (Client) |
Desktop App |
Dashboard |
AUTH_SECRET / AUTH_SECRET_KEY |
Yes |
No |
No |
No |
AUTH_PUBLIC_KEY (e.g. NEXT_PUBLIC_CLERK_KEY) |
No |
Yes |
Yes |
Yes |
AUTH_JWKS_URL |
Yes |
No |
No |
No |
PAYMENT_SECRET_KEY (Stripe, Paddle, etc.) |
Yes |
No |
No |
No |
PAYMENT_PUBLIC_KEY |
No |
Yes |
No |
No |
PAYMENT_WEBHOOK_SECRET |
Yes |
No |
No |
No |
DATABASE_URL |
Yes |
No |
No |
No |
REDIS_URL |
Yes |
No |
No |
No |
AI_API_KEY (OpenAI, Anthropic, etc.) |
Yes |
No |
No |
No |
API_URL (your backend URL) |
No |
Yes |
Yes |
Yes |
WEBSOCKET_URL |
No |
No |
Yes |
Optional |
JWT_SECRET (if custom auth) |
Yes |
No |
No |
No |
ADMIN_SECRET |
Yes |
No |
No |
Yes |
Rules:
- Anything prefixed
NEXT_PUBLIC_ is bundled into the client — never put secrets there
- Desktop apps ship to users' machines — never embed secrets in Electron code
- The backend is the only place that should hold secret keys
- Use
.env files locally, environment variables in production
User Authentication Flow
The Complete Flow
Backend - Auth Endpoint
// backend/routes/auth.ts
// Works with any auth provider — just swap verifyToken()
router.post('/auth/verify', async (req, res) => {
const { token } = req.body;
try {
const providerUser = await verifyToken(token);
// Get or create user in your database
let user = await db.users.findByProviderId(providerUser.id);
if (!user) {
user = await db.users.create({
providerId: providerUser.id,
email: providerUser.email,
credits: 100, // Free starter credits
plan: 'free',
});
}
res.json({
user: {
id: user.id,
email: user.email,
credits: user.credits,
plan: user.plan,
},
});
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
Desktop App - Auth Flow
// electron/auth.ts
import { BrowserWindow } from 'electron';
export async function signIn(): Promise<AuthResult> {
return new Promise((resolve, reject) => {
const authWindow = new BrowserWindow({
width: 500,
height: 700,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
// Load your auth provider's sign-in page
// Redirect back to a custom protocol: yourapp://auth-callback
const authUrl = buildAuthUrl({
redirectUri: 'yourapp://auth-callback',
});
authWindow.loadURL(authUrl);
// Listen for redirect with token
authWindow.webContents.on('will-redirect', async (event, url) => {
if (url.startsWith('yourapp://auth-callback')) {
const token = new URL(url).searchParams.get('token');
authWindow.close();
if (token) {
const response = await fetch(`${API_URL}/auth/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
resolve(await response.json());
} else {
reject(new Error('No token received'));
}
}
});
});
}
Store Token Securely
// electron/secure-store.ts
import { safeStorage } from 'electron';
import Store from 'electron-store';
const store = new Store();
// NOTE: safeStorage uses the OS keychain (macOS Keychain, Windows DPAPI, Linux libsecret)
// This runs in the Electron MAIN process only
export function saveToken(token: string) {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(token);
store.set('auth.token', encrypted.toString('base64'));
} else {
store.set('auth.token', token);
}
}
export function getToken(): string | null {
const stored = store.get('auth.token') as string;
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {
const buffer = Buffer.from(stored, 'base64');
return safeStorage.decryptString(buffer);
}
return stored;
}
AI Credits System
How Credits Work
Database Schema (Generic)
Regardless of your ORM or database, you need these tables/collections:
Users
id : unique identifier
providerId : auth provider's user ID
email : string, unique
credits : integer, default 0
plan : string (free, pro, enterprise)
createdAt : timestamp
updatedAt : timestamp
Purchases
id : unique identifier
userId : foreign key -> Users
amount : integer (cents)
credits : integer (credits purchased)
providerTxId : string, unique (Stripe session ID, Paddle tx ID, etc.)
status : string (pending, completed, refunded)
createdAt : timestamp
UsageLog
id : unique identifier
userId : foreign key -> Users
creditsUsed : integer
feature : string (text-generation, image-gen, etc.)
metadata : JSON (optional — prompt length, model used, etc.)
createdAt : timestamp
Credit Packages
// backend/config/credits.ts
export const CREDIT_PACKAGES = {
starter: {
id: 'starter',
name: 'Starter Pack',
credits: 500,
price: 999, // $9.99 in cents
},
popular: {
id: 'popular',
name: 'Popular Pack',
credits: 2000,
price: 2999,
savings: '25%',
},
pro: {
id: 'pro',
name: 'Pro Pack',
credits: 5000,
price: 5999,
savings: '40%',
},
};
export const FEATURE_COSTS: Record<string, number> = {
'text-generation': 1,
'text-generation-long': 5,
'image-generation': 10,
'code-assistance': 2,
'grammar-check': 1,
};
Using Credits in Desktop App
// desktop-app/services/ai.ts
export async function generateText(prompt: string): Promise<GenerationResult> {
const response = await fetch(`${API_URL}/ai/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify({ prompt, feature: 'text-generation' }),
});
if (response.status === 402) {
throw new InsufficientCreditsError('Not enough credits');
}
return response.json();
}
Backend - AI Generation with Credit Check
// backend/routes/ai.ts
router.post('/ai/generate', requireAuth, async (req, res) => {
const { prompt, feature } = req.body;
const userId = req.user.id;
const cost = FEATURE_COSTS[feature] || 1;
// Check credits
const user = await db.users.findById(userId);
if (!user || user.credits < cost) {
return res.status(402).json({
error: 'Insufficient credits',
required: cost,
available: user?.credits || 0,
});
}
// Deduct credits BEFORE calling AI (prevents race conditions)
await db.transaction(async (tx) => {
await tx.users.decrementCredits(userId, cost);
await tx.usageLogs.create({
userId,
creditsUsed: cost,
feature,
metadata: { promptLength: prompt.length },
});
});
try {
// Call AI provider (OpenAI, Anthropic, local model, etc.)
const result = await callAIProvider(prompt);
res.json({
result,
creditsUsed: cost,
creditsRemaining: user.credits - cost,
});
} catch (error) {
// Refund credits if AI call fails
await db.users.incrementCredits(userId, cost);
throw error;
}
});
Desktop App <-> Backend Communication
API Client Setup
// desktop-app/services/api.ts
import axios from 'axios';
import { getToken, clearToken } from './secure-store';
const api = axios.create({
baseURL: process.env.API_URL || 'https://api.yourapp.com',
timeout: 30000,
});
// Add auth token to all requests
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearToken();
// NOTE: In Electron, this runs in the renderer process.
// To communicate with the main process, use ipcRenderer.send('auth:logout')
window.dispatchEvent(new CustomEvent('auth:logout'));
}
return Promise.reject(error);
}
);
export default api;
Syncing User Data
// desktop-app/hooks/useUserSync.ts
import { useQuery, useEffect } from 'your-framework';
export function useUserSync() {
const { data, error } = useQuery({
queryKey: ['user', 'sync'],
queryFn: async () => {
const response = await api.get('/user/me');
return response.data;
},
refetchInterval: 5 * 60 * 1000, // Every 5 minutes
refetchOnWindowFocus: true,
});
// Update local store when data changes
// NOTE: If using TanStack Query v5+, use useEffect instead of onSuccess
useEffect(() => {
if (data) {
useUserStore.getState().setUser(data);
}
}, [data]);
return { data, error };
}
Desktop App State
// desktop-app/stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
credits: number;
plan: string;
}
interface UserState {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User) => void;
updateCredits: (credits: number) => void;
logout: () => void;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: true }),
updateCredits: (credits) =>
set((state) => ({
user: state.user ? { ...state.user, credits } : null,
})),
logout: () => set({ user: null, isAuthenticated: false }),
}),
{ name: 'user-store' }
)
);
Website (Download Page)
Components
Download Logic
// website/components/DownloadButton.tsx
import { useEffect, useState } from 'react';
type Platform = 'windows' | 'mac' | 'linux' | 'unknown';
function detectPlatform(): Platform {
if (typeof window === 'undefined') return 'unknown';
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('win')) return 'windows';
if (ua.includes('mac')) return 'mac';
if (ua.includes('linux')) return 'linux';
return 'unknown';
}
const DOWNLOAD_URLS = {
windows: 'https://releases.yourapp.com/latest/YourApp-Setup.exe',
mac: 'https://releases.yourapp.com/latest/YourApp.dmg',
linux: 'https://releases.yourapp.com/latest/YourApp.AppImage',
};
function DownloadButton() {
const [platform, setPlatform] = useState<Platform>('unknown');
useEffect(() => {
setPlatform(detectPlatform());
}, []);
const handleDownload = () => {
// Track download event
fetch('/api/analytics/track', {
method: 'POST',
body: JSON.stringify({ event: 'download_started', platform }),
});
window.location.href = DOWNLOAD_URLS[platform] || DOWNLOAD_URLS.windows;
};
return (
<div className="download-section">
<button className="download-btn primary" onClick={handleDownload}>
Download for {platform.charAt(0).toUpperCase() + platform.slice(1)}
</button>
<details className="other-platforms">
<summary>Other platforms</summary>
<a href={DOWNLOAD_URLS.windows}>Windows (.exe)</a>
<a href={DOWNLOAD_URLS.mac}>macOS (.dmg)</a>
<a href={DOWNLOAD_URLS.linux}>Linux (.AppImage)</a>
</details>
</div>
);
}
User Portal
// website/pages/dashboard.tsx
function UserDashboard() {
const user = useAuth(); // Your auth provider's hook
const { data: userData } = useQuery({
queryKey: ['user', 'dashboard'],
queryFn: async () => {
const res = await fetch('/api/user/dashboard');
return res.json();
},
});
return (
<div className="dashboard">
<h1>Welcome, {user?.firstName}</h1>
<div className="credits-card">
<h2>Your Credits</h2>
<p className="credits-balance">{userData?.credits || 0}</p>
<a href="/purchase" className="btn">Buy More Credits</a>
</div>
<div className="usage-history">
<h2>Recent Usage</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Feature</th>
<th>Credits Used</th>
</tr>
</thead>
<tbody>
{userData?.recentUsage?.map((log: any) => (
<tr key={log.id}>
<td>{new Date(log.createdAt).toLocaleDateString()}</td>
<td>{log.feature}</td>
<td>{log.creditsUsed}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Owner Dashboard
Dashboard Architecture
Dashboard API Endpoints
// backend/routes/admin.ts
// Get dashboard overview
router.get('/admin/overview', requireAdmin, async (req, res) => {
const [totalUsers, activeToday, revenueMTD, creditsPurchased] = await Promise.all([
db.users.count(),
db.users.countWhere({ lastActiveAt: { gte: oneDayAgo() } }),
db.purchases.sumWhere({ status: 'completed', createdAt: { gte: firstOfMonth() } }, 'amount'),
db.purchases.sumWhere({ status: 'completed', createdAt: { gte: firstOfMonth() } }, 'credits'),
]);
res.json({ totalUsers, activeToday, revenueMTD, creditsPurchased });
});
// Get all users with pagination
router.get('/admin/users', requireAdmin, async (req, res) => {
const { page = 1, limit = 20, search } = req.query;
const where = search
? { or: [{ email: { contains: search } }, { id: { contains: search } }] }
: {};
const [users, total] = await Promise.all([
db.users.findMany({
where,
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
orderBy: { createdAt: 'desc' },
}),
db.users.count({ where }),
]);
res.json({
users,
pagination: {
page: Number(page),
limit: Number(limit),
total,
totalPages: Math.ceil(total / Number(limit)),
},
});
});
// Get single user details
router.get('/admin/users/:id', requireAdmin, async (req, res) => {
const user = await db.users.findById(req.params.id, {
include: { purchases: { limit: 10 }, usageHistory: { limit: 50 } },
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// Give credits to user (admin action)
router.post('/admin/users/:id/credits', requireAdmin, async (req, res) => {
const { credits, reason } = req.body;
const user = await db.users.incrementCredits(req.params.id, credits);
// Audit log
await db.adminLogs.create({
adminId: req.user.id,
action: 'give_credits',
targetUserId: req.params.id,
metadata: { credits, reason },
});
// Notify user's desktop app
await notifyUser(req.params.id, {
type: 'credits_received',
credits,
message: reason || 'Credits added by admin',
});
res.json({ success: true, newBalance: user.credits });
});
Real-time Sync
WebSocket Connection
WebSocket Server
// backend/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Map of userId -> active WebSocket connections
const userConnections = new Map<string, Set<WebSocket>>();
wss.on('connection', async (ws, req) => {
// SECURITY NOTE: Passing tokens in query strings (wss://...?token=X) is common
// but leaks tokens in server logs and proxy logs. Alternatives:
// 1. Send token as the first message after connection opens
// 2. Use a short-lived ticket: client gets a ticket via REST, passes ticket in URL
// 3. Use the Sec-WebSocket-Protocol header
//
// For simplicity, this example uses query string auth:
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'No token provided');
return;
}
try {
const decoded = await verifyToken(token);
const userId = decoded.userId;
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
userConnections.get(userId)!.add(ws);
ws.on('close', () => {
userConnections.get(userId)?.delete(ws);
if (userConnections.get(userId)?.size === 0) {
userConnections.delete(userId);
}
});
ws.send(JSON.stringify({ type: 'connected', userId }));
} catch (error) {
ws.close(4002, 'Invalid token');
}
});
// Send a message to all of a user's connected clients
export function notifyUser(userId: string, message: object) {
const connections = userConnections.get(userId);
if (connections) {
const payload = JSON.stringify(message);
connections.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
}
}
Desktop App WebSocket Client
// desktop-app/services/websocket.ts
import { useUserStore } from '../stores/userStore';
class WebSocketClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
connect(token: string) {
this.ws = new WebSocket(`wss://api.yourapp.com/ws?token=${token}`);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
}
private handleMessage(message: any) {
switch (message.type) {
case 'credits_added':
case 'credits_received':
useUserStore.getState().updateCredits(message.newBalance);
new Notification('Credits Added!', {
body: `You received ${message.credits} credits`,
});
break;
case 'credits_removed':
useUserStore.getState().updateCredits(message.newBalance);
break;
case 'plan_updated':
useUserStore.getState().setUser({
...useUserStore.getState().user!,
plan: message.plan,
});
break;
case 'force_logout':
useUserStore.getState().logout();
break;
}
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = Math.pow(2, this.reconnectAttempts) * 1000;
this.reconnectAttempts++;
setTimeout(() => {
const token = getToken();
if (token) this.connect(token);
}, delay);
}
disconnect() {
this.ws?.close();
this.ws = null;
}
}
export const wsClient = new WebSocketClient();
Real-time Alternatives to WebSockets
| Technology |
Best For |
Notes |
| WebSockets (ws, Socket.IO) |
Bidirectional real-time |
Most flexible, most work |
| Server-Sent Events (SSE) |
Server-to-client only |
Simpler, works over HTTP, auto-reconnect |
| Supabase Realtime |
Already using Supabase |
Subscribe to database changes directly |
| Firebase Realtime DB |
Already using Firebase |
Automatic sync, offline support built in |
| Pusher / Ably |
Managed real-time |
No infrastructure to manage, paid |
| Long polling |
Fallback when WS blocked |
Works everywhere, higher latency |
Offline Support
Handling Offline Mode in Desktop App
Offline Queue
// desktop-app/services/offlineQueue.ts
// Storage options for Electron:
// - electron-store: Simple key-value, main process (recommended for small queues)
// - IndexedDB via Dexie: Renderer process, structured data, larger capacity
// - SQLite (better-sqlite3): Main process, full SQL, best for complex queries
// - Filesystem: Main process, raw JSON files
//
// This example uses a generic interface — swap the implementation:
interface QueuedAction {
id: string;
action: string;
payload: any;
createdAt: Date;
retries: number;
}
// Generic interface — implement with your chosen storage
interface QueueStorage {
add(action: QueuedAction): Promise<void>;
getAll(): Promise<QueuedAction[]>;
remove(id: string): Promise<void>;
updateRetries(id: string, retries: number): Promise<void>;
}
export class OfflineQueue {
constructor(private storage: QueueStorage) {}
async enqueue(action: string, payload: any) {
await this.storage.add({
id: crypto.randomUUID(),
action,
payload,
createdAt: new Date(),
retries: 0,
});
}
async processQueue() {
const actions = await this.storage.getAll();
for (const action of actions) {
try {
await executeAction(action);
await this.storage.remove(action.id);
} catch (error) {
if (action.retries >= 3) {
await this.storage.remove(action.id);
} else {
await this.storage.updateRetries(action.id, action.retries + 1);
}
}
}
}
}
// Listen for online status (renderer process)
window.addEventListener('online', () => {
offlineQueue.processQueue();
});
Offline-aware API Calls
// desktop-app/services/api.ts
export async function makeRequest(endpoint: string, options: RequestInit) {
if (!navigator.onLine) {
await offlineQueue.enqueue('api_request', { endpoint, options });
throw new OfflineError('You are offline. Request queued.');
}
return fetch(`${API_URL}${endpoint}`, options);
}
Auto-Updates for Desktop
Electron apps should check for updates and prompt users to install them.
Update Flow
Implementation
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
// Check on launch
autoUpdater.checkForUpdatesAndNotify();
// Check periodically (every 4 hours)
setInterval(() => {
autoUpdater.checkForUpdatesAndNotify();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('update:available', info.version);
});
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('update:downloaded', info.version);
});
// Called from renderer when user clicks "Restart and Update"
ipcMain.on('update:install', () => {
autoUpdater.quitAndInstall();
});
}
Where to Host Updates
| Option |
How It Works |
Cost |
| GitHub Releases |
electron-updater reads from GitHub releases |
Free for public repos |
| S3 / R2 / GCS |
Upload builds to a bucket, point electron-updater at it |
Cheap storage costs |
| Hazel (Vercel) |
Tiny server that proxies GitHub releases |
Free on Vercel |
| update.electronjs.org |
Free update server for open-source Electron apps |
Free |
| Your own server |
Serve update files from your backend |
You manage it |
In electron-builder.yml:
publish:
provider: github # or s3, generic, etc.
owner: your-username
repo: your-app
Error Handling Strategy
Error Propagation
When something fails, errors need to flow back to the user clearly.
Backend Error Responses
Use consistent error shapes:
// backend/middleware/errorHandler.ts
interface ApiError {
error: string; // Machine-readable code
message: string; // Human-readable message
retryable: boolean; // Should the client retry?
retryAfter?: number; // Seconds to wait before retry
}
// Error categories:
// 400 — Bad request (client's fault, don't retry)
// 401 — Not authenticated (redirect to login)
// 402 — Insufficient credits (prompt purchase)
// 403 — Not authorized (wrong role)
// 429 — Rate limited (retry after delay)
// 500 — Server error (retry with backoff)
// 502/503 — Upstream down (retry with backoff)
export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
// AI provider errors — refund credits and tell user to retry
if (err.source === 'ai_provider') {
return res.status(502).json({
error: 'ai_provider_error',
message: 'AI service is temporarily unavailable. Your credits have been refunded.',
retryable: true,
retryAfter: 30,
});
}
// Payment provider errors
if (err.source === 'payment_provider') {
return res.status(502).json({
error: 'payment_error',
message: 'Payment service is temporarily unavailable. Please try again.',
retryable: true,
});
}
// Default
console.error('Unhandled error:', err);
res.status(500).json({
error: 'internal_error',
message: 'Something went wrong. Please try again.',
retryable: true,
});
}
Desktop App Error Handling
// desktop-app/services/api.ts
api.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
const data = error.response?.data;
switch (status) {
case 401:
// Token expired — force re-login
clearToken();
showLoginScreen();
break;
case 402:
// Insufficient credits — show purchase prompt
showPurchasePrompt(data.required, data.available);
break;
case 429:
// Rate limited — show countdown
showRateLimitMessage(data.retryAfter);
break;
case 502:
case 503:
// Upstream down — show retry option
if (data?.retryable) {
showRetryPrompt(data.message, data.retryAfter);
}
break;
default:
showGenericError(data?.message || 'Something went wrong');
}
return Promise.reject(error);
}
);
Deployment Topology
Where each piece runs in production:
| Component |
Where to Deploy |
Examples |
| Backend API |
Server / container / serverless |
Railway, Fly.io, Render, AWS ECS, DigitalOcean App Platform, VPS |
| WebSocket Server |
Same as backend or separate |
Same server (different port) or dedicated instance |
| Primary Database |
Managed database service |
Supabase, Neon, PlanetScale, AWS RDS, Railway Postgres, MongoDB Atlas |
| Cache (Redis) |
Managed or sidecar |
Upstash, Railway Redis, AWS ElastiCache, self-hosted |
| Website |
Static hosting or SSR platform |
Vercel, Netlify, Cloudflare Pages, AWS Amplify |
| Admin Dashboard |
Same as website or separate |
Same platform, different subdomain (admin.yourapp.com) |
| Desktop App |
Distributed to users |
GitHub Releases, S3/R2, your own CDN |
| File Storage |
Object storage |
S3, R2, GCS, Supabase Storage |
| Monitoring |
Observability platform |
Sentry, Datadog, Grafana Cloud, BetterStack |
| Email |
Transactional email service |
Resend, SendGrid, Postmark, AWS SES |
Example Production Setup (Budget-Friendly)
Backend API -> Railway ($5/mo)
PostgreSQL -> Neon (free tier) or Supabase (free tier)
Redis -> Upstash (free tier)
Website -> Vercel (free tier)
Dashboard -> Vercel (same project, /admin route)
Desktop Releases -> GitHub Releases (free)
Auth -> Clerk (free tier) or Auth.js (free)
Payments -> Stripe (pay per transaction)
Email -> Resend (free tier)
Monitoring -> Sentry (free tier)
Example Production Setup (Scale)
Backend API -> AWS ECS or Fly.io (auto-scaling)
PostgreSQL -> AWS RDS or Supabase Pro
Redis -> AWS ElastiCache or Upstash Pro
Website -> Vercel Pro
Dashboard -> Separate Vercel project
Desktop Releases -> S3 + CloudFront CDN
Auth -> Clerk Pro or self-hosted Keycloak
Payments -> Stripe
Email -> SendGrid or AWS SES
Monitoring -> Datadog or Grafana Cloud
Security Considerations
Security Checklist
Rate Limiting
// backend/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
// General API rate limit
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: { error: 'Too many requests, please try again later' },
});
// Strict limit for auth endpoints
export const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
message: { error: 'Too many login attempts' },
});
// AI endpoint limit
export const aiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20,
message: { error: 'Rate limit exceeded' },
});
Security Best Practices
| Area |
Do |
Don't |
| Tokens |
Short expiry (15min-24h), refresh rotation |
Long-lived tokens, tokens in localStorage on web |
| Secrets |
Backend-only, env vars, secret managers |
Embed in desktop app code, commit to git |
| Webhooks |
Always verify signatures |
Trust payload without verification |
| CORS |
Whitelist specific origins |
allow_origins: ["*"] in production |
| Input |
Validate and sanitize all user input |
Trust client-side validation alone |
| Admin |
Separate auth, IP allowlisting, audit logs |
Same auth as regular users |
| Desktop |
safeStorage for tokens, code signing |
Plain text token storage |
| Database |
Parameterized queries, least-privilege accounts |
String concatenation in queries |
| HTTPS |
Enforce everywhere, HSTS headers |
Allow HTTP in production |
Implementation Checklist
Phase 1: Foundation
Phase 2: Desktop App Integration
Phase 3: Website
Phase 4: Admin Dashboard
Phase 5: Real-time Features
Phase 6: Polish
Summary
+------------------------------------------------------------------+
| |
| Desktop App Website Dashboard |
| | | | |
| +-----REST/WS---------+-------REST----------+ |
| | |
| Central Backend |
| | |
| +---------------+---------------+ |
| | | | |
| Auth Provider Payment Provider AI Provider |
| | | | |
| +-------+-------+ | |
| | | |
| Database + Cache | |
+------------------------------------------------------------------+
Key Takeaways:
- One Backend serves all three apps (desktop, website, dashboard)
- Auth tokens are shared across platforms — verify on the backend, trust no client
- Credits are stored centrally and checked on every AI request (deduct before calling, refund on failure)
- Webhooks notify your system of payment and auth events — always verify signatures
- WebSockets (or SSE, or a managed service) keep the desktop app in sync with purchases
- Admin dashboard gives you full control over users, credits, and revenue
- Everything is swappable — the patterns stay the same regardless of which database, auth provider, or payment system you choose