Use this when converting a simple single-user Vite + Express media app into a multi-user app with: - signup/login - cookie sessions - per-user third-party API keys - DB-backed gallery - S3-backed persistent media storage
This pattern was used for fal-studio.
Frontend: - Vite + React single-page app - tabs for Create / Gallery / Account - auth screen before app access - account tab stores the user's own fal key and shows balance
Backend:
- Express single-port server
- SQLite via better-sqlite3
- password hashing via bcryptjs
- session cookie via jsonwebtoken + cookie-parser
- S3 persistence via AWS SDK v3 against Hetzner Object Storage
npm install better-sqlite3 bcryptjs jsonwebtoken cookie-parser @aws-sdk/client-s3 dotenv
Create at minimum:
id INTEGER PRIMARY KEY AUTOINCREMENTemail TEXT UNIQUE NOT NULLpassword_hash TEXT NOT NULLfal_key TEXTcreated_at TEXT DEFAULT datetime('now')id TEXT PRIMARY KEYuser_id INTEGER NOT NULLmodel TEXT NOT NULLprompt TEXT NOT NULLresult_kind TEXT NOT NULL (image or video)storage_key TEXT NOT NULLstorage_url TEXT NOT NULLparams_json TEXTcreated_at TEXT DEFAULT datetime('now')Use middleware that:
- reads cookie
- verifies JWT
- loads current user from DB
- attaches req.user
Never return full DB row. Return only:
{
id,
email,
hasFalKey: !!fal_key,
}
For fal-studio, do NOT use a global key in app state.
Store each user's fal key in the users table and always use:
- req.user.fal_key
for generation, model access checks, and credit lookup.
Use user-scoped object keys such as:
- users/{userId}/image/{timestamp}-{slug}.png
- users/{userId}/video/{timestamp}-{slug}.mp4
This prevents cross-user collisions and makes cleanup simple.
req.user.fal_keyfal.run/{model} using that keyExpose an authenticated endpoint such as:
- GET /api/me/fal-credits
Important experiential findings:
- The working documented billing endpoint here was:
- GET https://api.fal.ai/v1/account/billing?expand=credits
- Earlier rest.alpha.fal.ai/... and rest.fal.ai/... billing attempts returned 404.
- Do not assume one fixed billing URL will work forever; fallback logic is still reasonable, but prefer the documented api.fal.ai/v1/... route first.
- Normalize both data.credits and data.billing.credits response shapes.
- Normalize both credits.balance and credits.current_balance because the live response here used current_balance.
Also important:
- Billing/credit lookup may require an ADMIN fal key even when the same key type can still run model inference.
- If the billing endpoint returns 403 mentioning ADMIN keys, surface a user-friendly message like:
- fal credit checks require an ADMIN fal API key. Your current key can run models but cannot read billing balance.
- A 401 auth error across many different fal inference endpoints still usually means a bad key, not model activation.
Recommended controls: - current user email - whether fal key is saved - input to save/update fal key - credit balance card - refresh credits button - logout button
Write lightweight Node tests first for reusable modules: - auth helpers - DB helpers - storage key normalization - billing/credit helper - model upload section logic
Run with:
node --test auth-db.test.mjs fal-credits.test.mjs model-utils.test.mjs
npm run build
--update-env/api/status
- signup/login
- save fal key
- gallery route
- existing unrelated apps still return 200.env is usually gitignored; do not try to commit it.--update-env after adding env vars.better-sqlite3 is fine here because the app is single-node and PM2 runs one process.videoUrl / imageUrl / storageUrl / local fallback URL) over the raw provider previewUrl. If you prioritize previewUrl first, the job can succeed and save correctly but the UI can still show a generic Load failed when the temporary provider URL expires or becomes inaccessible.getPrimaryResult, getAssetUrl, and any preview convenience variable in the main component). Don't fix only one path.saved locally instead but still render from the local asset URL so the user sees a successful result immediately.credentials: 'include'.Blocked by provider safety/content policy: ... instead of leaving them as vague Generation failed or blocked request messages. This makes it clear when a failure really was content sensitivity versus a broken preview URL.