Runtime Settings
Store payment, storage, newsletter, and Turnstile settings in the database. Admins edit them at /app/admin/settings.
Payment gateway credentials, S3 storage, the public avatar read URL, the newsletter signing secret, and Turnstile live in the app_setting table. Admins maintain them at /app/admin/settings.
Mail delivery, Better Auth, and OAuth stay in env. An empty newsletter secret falls back to BETTER_AUTH_SECRET. An empty avatar read URL falls back to AVATARS_FILE_PROXY_URL_FALLBACK in src/config/storage.ts.
First admin
Registration still uses env-based mail (OTP). Create the first user with pnpm muse:user:create, then promote them with pnpm muse:user:role. After that, open /app/admin/settings.
What stays in env vs what moves to the admin page
| Surface | Source | Why |
|---|---|---|
| Site URL, database, Better Auth, OAuth | Env | Needed before any admin session exists |
Mail (MAIL_PROVIDER, Resend / Cloudflare) | Env | Registration OTP must work with an empty settings table |
MUSE_SETTINGS_SECRET | Env | Wrapping key for encrypted settings; never stored in the database |
| Payment gateways, webhook secrets, default gateway | Admin settings | Server-only secrets; encrypted at rest |
| Checkout product IDs | Admin settings (billing.productMap) | Checkout source of truth |
| S3 access key, secret, endpoint, avatars bucket, public read URL | Admin settings (Storage) | Credentials are encrypted. The read URL is sent to signed-in clients on /api/auth/get-session |
| Newsletter signing secret | Admin settings (Newsletter) | Optional. Empty uses BETTER_AUTH_SECRET. A new value invalidates existing unsubscribe links |
| Turnstile site key and secret | Admin settings (Captcha) | Both must be saved before forgot-password shows a captcha |
First-deploy order
Set BETTER_AUTH_SECRET, MUSE_SETTINGS_SECRET, database, and mail env vars. MUSE_SETTINGS_SECRET must be at least 32 characters.
Apply migrations (pnpm drizzle:migrate) so app_setting and app_setting_revision exist.
Create and promote an admin (pnpm muse:user:create, then pnpm muse:user:role).
Open /app/admin/settings. Save gateway credentials, checkout product IDs, and S3 settings. Use Test connection before going live. The Storage tab also holds the public avatar read URL. Newsletter and Captcha are separate tabs.
The billing tab keeps the Default gateway card at the top. Each provider (Creem, Stripe, Dodo, Waffo) starts collapsed. Expand a provider to edit its credentials and that gateway's checkout product IDs (Pro monthly / yearly / Lifetime) in the same panel. Badges show Default, Configured / Not configured, mapped product-ID count, and Unsaved.
Optional: pnpm muse:settings:import copies leftover local gateway/storage credentials into the database once. Runtime never reads those env vars afterward. Checkout product IDs are entered in Admin → Settings, not env.
Encryption
Secret fields are stored as unprefixed base64url ciphertext. Each row uses HKDF-SHA256 (info bound to scope:key) plus AES-256-GCM with additional data bound to the same identity. Moving ciphertext to another field fails decryption. cipher_version records the algorithm; 0 is plaintext for non-secret fields.
Capability fallback
If no gateway is stored, checkout CTAs on signed-in billing pages are disabled and launch returns a readable error. If S3 is missing, avatar/file APIs return STORAGE_ENV_MISSING (503) and the storage page shows the empty state. The public homepage stays static and does not query settings.
Cache
Settings are cached in-process and keyed by app_setting_revision. Other instances refresh within 30 seconds.