6 Commits
Author SHA1 Message Date
Tomáš Dvořák 49ca9067a9 Merge pull request #41 from Dvorinka/release/v0.8.2
chore(release): v0.8.2
2026-09-28 16:28:11 +02:00
Tomas DvorakandDevin 805a58e32f chore(release): v0.8.2
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:26:11 +02:00
Tomáš Dvořák d09b0a1c2d Merge pull request #40 from Dvorinka/fix/signup-bounce
fix(auth): sign-up bounce — surface errors, dynamic trusted origins, unbreakable mail probe
2026-09-28 16:25:24 +02:00
Tomas DvorakandDevin 92b1810c07 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>
2026-09-28 16:23:01 +02:00
Tomáš Dvořák 3ad37c190b Merge pull request #39 from Dvorinka/fix/sbom-output-dir
fix: create dist-release dir before SBOM output writes
2026-09-28 16:02:16 +02:00
Tomas DvorakandDevin cb56cedb1f fix: create dist-release dir before SBOM output writes
anchore/sbom-action does not create the output directory — the v0.8.1
run scanned images fine (lowercase owner works) but ENOENT'd writing
dist-release/sbom-*.cdx.json.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:00:00 +02:00
8 changed files with 52 additions and 7 deletions

No files matched your search

+2
View File
@@ -339,6 +339,8 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# anchore/sbom-action does not create the output directory.
- run: mkdir -p dist-release
- uses: anchore/sbom-action@v0 - uses: anchore/sbom-action@v0
with: with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }} image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
+8
View File
@@ -4,6 +4,14 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
## [Unreleased] ## [Unreleased]
## [0.8.2] - 2026-09-28
### 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 ## [0.8.1] - 2026-09-28
### Fixed ### Fixed
+19 -2
View File
@@ -53,7 +53,19 @@ export const auth = betterAuth({
appName: "Primora", appName: "Primora",
secret: env.BETTER_AUTH_SECRET, secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL, 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, database: authPool,
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@@ -155,10 +167,15 @@ export async function runAuthMigrations() {
await migrations.runMigrations(); 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() { export async function verifyUsersWithoutMailTransport() {
try {
if (await hasMailTransport()) return; if (await hasMailTransport()) return;
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`); 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. */ /** Bootstrap admins by email — run after migrations so the role column exists. */
+7
View File
@@ -32,10 +32,17 @@ async function resolveMailConfig() {
// True when a mail transport exists (Resend key or SMTP host). Verification // 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. // 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> { export async function hasMailTransport(): Promise<boolean> {
try {
const cfg = await resolveMailConfig(); const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true; if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp); 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: { export async function sendTransactionalEmail(input: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.8.1", "version": "0.8.2",
"private": true, "private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment", "description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Primora", "productName": "Primora",
"version": "0.8.1", "version": "0.8.2",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+13 -2
View File
@@ -735,11 +735,22 @@ export default function App() {
await runPending("auth-submit", async () => { await runPending("auth-submit", async () => {
setAuthMessage(""); setAuthMessage("");
try { 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") { 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."); setAuthMessage("Account created — signing you in.");
} else { } 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) { } catch (error) {
setAuthMessage(getErrorMessage(error, "Authentication failed")); setAuthMessage(getErrorMessage(error, "Authentication failed"));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "primora", "name": "primora",
"version": "0.8.1", "version": "0.8.2",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [