fix(auth): surface sign-up failures, trust serving origin, unbreakable mail probe

Self-hosted sign-up bounced back to the bootstrap screen for three
compounding reasons:

- better-auth's client resolves { data, error } on HTTP failure; the UI
  ignored res.error and showed "Account created — signing you in", hiding
  the real 403/429/500.
- trustedOrigins was a static localhost-only list. Browsing the instance
  via LAN IP or custom port failed every auth POST once cookies existed.
  Origins are now derived from the request's public host (forwarded by
  nginx), so any origin serving the app works.
- hasMailTransport threw when core.settings was unreadable (fresh volume,
  backend still migrating), so sign-up 500'd and the startup backfill
  could crash-loop the service. Both now degrade to "no transport".

Verified live: LAN-style origin sign-up succeeds, missing core.settings
logs a warn instead of exiting, and the frontend shows real errors.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-28 16:23:01 +02:00
1 parent 3ad37c190b
commit 92b1810c07
4 files changed
+50 -9

No files matched your search

+21 -4
View File
@@ -53,7 +53,19 @@ export const auth = betterAuth({
appName: "Primora",
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
// Self-host: the browser reaches /auth through the same nginx origin as the
// app, so whatever host actually served this request is trusted — static
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
// An attacker's cross-site Origin still won't match the server host.
trustedOrigins: (request) => {
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
if (host) {
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
origins.push(`${proto}://${host}`);
}
return origins;
},
database: authPool,
emailAndPassword: {
enabled: true,
@@ -155,10 +167,15 @@ export async function runAuthMigrations() {
await migrations.runMigrations();
}
/** No transport → existing unverified users can never resolve that state. */
/** No transport → existing unverified users can never resolve that state.
* Best-effort: a startup failure here must never take the service down. */
export async function verifyUsersWithoutMailTransport() {
if (await hasMailTransport()) return;
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
try {
if (await hasMailTransport()) return;
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
}
}
/** Bootstrap admins by email — run after migrations so the role column exists. */
+10 -3
View File
@@ -32,10 +32,17 @@ async function resolveMailConfig() {
// True when a mail transport exists (Resend key or SMTP host). Verification
// and reset mails only make sense when mail can actually leave the box.
// Mail is optional — a failed settings read must degrade to "no transport",
// never break sign-up.
export async function hasMailTransport(): Promise<boolean> {
const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp);
try {
const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
return false;
}
}
export async function sendTransactionalEmail(input: {