diff --git a/CHANGELOG.md b/CHANGELOG.md index eb349a8..13fbb0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac ## [Unreleased] +### Fixed + +- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply. +- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed. +- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window. + ## [0.8.1] - 2026-09-28 ### Fixed diff --git a/apps/auth/src/lib/auth.ts b/apps/auth/src/lib/auth.ts index 2357413..3f79312 100644 --- a/apps/auth/src/lib/auth.ts +++ b/apps/auth/src/lib/auth.ts @@ -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. */ diff --git a/apps/auth/src/lib/mail.ts b/apps/auth/src/lib/mail.ts index 5a453fd..62910ad 100644 --- a/apps/auth/src/lib/mail.ts +++ b/apps/auth/src/lib/mail.ts @@ -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 { - 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: { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 53b4355..d25f05d 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -735,11 +735,22 @@ export default function App() { await runPending("auth-submit", async () => { setAuthMessage(""); try { + // better-auth client resolves { data, error } instead of throwing on + // HTTP failures — surface res.error or the UI claims success while + // nothing was created. if (mode() === "sign-up") { - await authClient.signUp.email({ email: email(), password: password(), name: name() }); + const res = await authClient.signUp.email({ email: email(), password: password(), name: name() }); + if (res.error) { + setAuthMessage(res.error.message ?? "Sign-up failed"); + return; + } setAuthMessage("Account created — signing you in."); } else { - await authClient.signIn.email({ email: email(), password: password() }); + const res = await authClient.signIn.email({ email: email(), password: password() }); + if (res.error) { + setAuthMessage(res.error.message ?? "Sign-in failed"); + return; + } } } catch (error) { setAuthMessage(getErrorMessage(error, "Authentication failed"));