--- name: fal-studio-auth-s3 description: Add multi-user auth, per-user fal keys, SQLite persistence, and Hetzner S3-backed media storage to a Vite+Express app like fal-studio. version: 1.0.0 author: Hermes Agent license: MIT metadata: hermes: tags: [fal-studio, auth, sqlite, s3, hetzner, express, vite, per-user api keys, media storage] related_skills: [hetzner-s3-storage, vps-app-deployment, test-driven-development] --- # fal-studio auth + S3 migration 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. ## Recommended architecture 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 ## Dependencies ```bash npm install better-sqlite3 bcryptjs jsonwebtoken cookie-parser @aws-sdk/client-s3 dotenv ``` ## DB schema Create at minimum: ### users - `id INTEGER PRIMARY KEY AUTOINCREMENT` - `email TEXT UNIQUE NOT NULL` - `password_hash TEXT NOT NULL` - `fal_key TEXT` - `created_at TEXT DEFAULT datetime('now')` ### assets - `id TEXT PRIMARY KEY` - `user_id INTEGER NOT NULL` - `model TEXT NOT NULL` - `prompt TEXT NOT NULL` - `result_kind TEXT NOT NULL` (`image` or `video`) - `storage_key TEXT NOT NULL` - `storage_url TEXT NOT NULL` - `params_json TEXT` - `created_at TEXT DEFAULT datetime('now')` ## Auth pattern ### Signup 1. validate email/password 2. hash password with bcrypt 3. insert user row 4. sign session JWT 5. set secure httpOnly cookie 6. return safe user object only ### Login 1. lookup by email 2. compare password with bcrypt 3. sign session JWT 4. set cookie 5. return safe user object only ### Protected routes Use middleware that: - reads cookie - verifies JWT - loads current user from DB - attaches `req.user` ## Safe user shape Never return full DB row. Return only: ```js { id, email, hasFalKey: !!fal_key, } ``` ## Per-user provider key pattern 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. ## Media persistence pattern ### Before - local JSON state - local files under data/gallery ### After - generated media fetched from provider output URL - uploaded immediately to Hetzner S3 - metadata stored in SQLite - gallery reads from DB ### S3 key pattern 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. ## fal generation flow 1. require authenticated user 2. require `req.user.fal_key` 3. upload local reference images to fal CDN using that key 4. call `fal.run/{model}` using that key 5. detect result image vs video from response 6. download provider output 7. upload final media to Hetzner S3 8. create DB asset row 9. return S3 URL to frontend ## fal billing / credits pattern Expose 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. ## Frontend account tab Recommended controls: - current user email - whether fal key is saved - input to save/update fal key - credit balance card - refresh credits button - logout button ## Frontend auth UX improvements worth keeping - separate login/signup modes - confirm password on signup - minimum password length validation - success/error banners - disable generate button until user has saved a fal key ## Testing approach used Write lightweight Node tests first for reusable modules: - auth helpers - DB helpers - storage key normalization - billing/credit helper - model upload section logic Run with: ```bash node --test auth-db.test.mjs fal-credits.test.mjs model-utils.test.mjs npm run build ``` ## Deployment checklist 1. add env vars for session secret + Hetzner S3 2. build app 3. restart PM2 with `--update-env` 4. verify: - `/api/status` - signup/login - save fal key - gallery route - existing unrelated apps still return 200 ## Pitfalls - `.env` is usually gitignored; do not try to commit it. - PM2 needs `--update-env` after adding env vars. - `better-sqlite3` is fine here because the app is single-node and PM2 runs one process. - If provider media URLs are temporary, always re-upload immediately to S3. - After generation, the frontend should prefer the persisted asset URL (`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. - Use the same persisted-URL preference in both the active result preview and gallery helpers (`getPrimaryResult`, `getAssetUrl`, and any preview convenience variable in the main component). Don't fix only one path. - If S3 upload fails and you fall back to local storage, surface a friendly notice like `saved locally instead` but still render from the local asset URL so the user sees a successful result immediately. - Cookie-authenticated frontend fetches must use `credentials: 'include'`. - When testing provider credit endpoints, 404 may indicate endpoint drift, not auth failure. - When users report a provider 401, verify whether the stored key is invalid before telling them to activate models. - For fal/provider moderation failures, map 403/422 responses that mention safety/policy/moderation/sensitive/unsafe into explicit user-facing text such as `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.