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
Tomáš Dvořák 20b4ea6a07 Merge pull request #38 from Dvorinka/release/v0.8.1
release: v0.8.1
2026-09-28 15:48:12 +02:00
Tomas DvorakandDevin 0511e5754b release: v0.8.1 — SMTP-less auth fix
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:45:54 +02:00
Tomáš Dvořák 3800e736d0 Merge pull request #37 from Dvorinka/fix/smtpless-auth-verification
fix: skip email verification entirely when no mail transport exists
2026-09-28 15:45:16 +02:00
Tomas DvorakandDevin 7548ea75d4 fix: skip email verification entirely when no mail transport exists
SMTP-less self-hosted instances still marked every account
emailVerified=false and attempted a verification mail that could
never arrive. Now: no transport → accounts are created verified,
the send is skipped, and existing unverified rows are backfilled
on startup. .env.example stops defaulting SMTP_HOST to the
dev-only mailpit service.

Verified live: fresh signup returns emailVerified=true, legacy
unverified users flip on restart, sign-in unaffected.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:43:26 +02:00
Tomáš Dvořák 14d0762245 Merge pull request #36 from Dvorinka/chore/gitignore-brag-output
chore: gitignore local brag-output directory
2026-09-28 15:29:31 +02:00
Tomas DvorakandDevin a990989d34 chore: gitignore local brag-output directory
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:27:21 +02:00
Tomáš Dvořák 3b9ea333ad Merge pull request #35 from Dvorinka/fix/sbom-ghcr-case
fix: lowercase GHCR owner in SBOM image refs
2026-09-28 13:55:59 +02:00
Tomas DvorakandDevin 86d612ec93 fix: lowercase GHCR owner in SBOM image refs
ghcr.io rejects mixed-case repo paths — syft failed to parse
ghcr.io/Dvorinka/primora-* so the v0.8.0 SBOM job errored.
Apply the same lowercase-owner normalization the image jobs use.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 13:52:51 +02:00
Tomáš Dvořák 0c5d4a72b2 Merge pull request #34 from Dvorinka/fix/release-native-arm-images
fix: build release images on native arch runners
2026-09-28 13:40:19 +02:00
Tomáš Dvořák f6751b70d4 Merge pull request #33 from Dvorinka/release/v0.8.0
chore: release v0.8.0
2026-09-28 11:36:26 +02:00
11 changed files with 111 additions and 24 deletions

No files matched your search

+5 -1
View File
@@ -77,9 +77,13 @@ BACKEND_STORAGE_DRIVER=local
# Mail defaults — each is overridable in-app (Settings → Instance). In-app # Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here. # values win over these env vars; deleting the in-app value reverts here.
# With no transport configured (no Resend key and no SMTP_HOST) accounts are
# verified automatically and no verification mail is attempted.
# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml)
# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025.
RESEND_API_KEY= RESEND_API_KEY=
MAIL_FROM=Primora <[email protected]> MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit SMTP_HOST=
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
+8 -3
View File
@@ -331,24 +331,29 @@ jobs:
VERSION: ${{ needs.verify.outputs.version }} VERSION: ${{ needs.verify.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
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/${{ github.repository_owner }}/primora-backend:${{ needs.verify.outputs.version }} image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
format: cyclonedx-json format: cyclonedx-json
output-file: dist-release/sbom-backend.cdx.json output-file: dist-release/sbom-backend.cdx.json
- uses: anchore/sbom-action@v0 - uses: anchore/sbom-action@v0
with: with:
image: ghcr.io/${{ github.repository_owner }}/primora-auth:${{ needs.verify.outputs.version }} image: ghcr.io/${{ env.GHCR_OWNER }}/primora-auth:${{ needs.verify.outputs.version }}
format: cyclonedx-json format: cyclonedx-json
output-file: dist-release/sbom-auth.cdx.json output-file: dist-release/sbom-auth.cdx.json
- uses: anchore/sbom-action@v0 - uses: anchore/sbom-action@v0
with: with:
image: ghcr.io/${{ github.repository_owner }}/primora-frontend:${{ needs.verify.outputs.version }} image: ghcr.io/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }}
format: cyclonedx-json format: cyclonedx-json
output-file: dist-release/sbom-frontend.cdx.json output-file: dist-release/sbom-frontend.cdx.json
- uses: anchore/sbom-action@v0 - uses: anchore/sbom-action@v0
+2
View File
@@ -39,3 +39,5 @@ infra/mailpit
# Generated code-graph artifacts # Generated code-graph artifacts
graphify-out/ graphify-out/
.playwright-mcp/ .playwright-mcp/
# Local brag/launch-video output
brag-output/
+14
View File
@@ -4,6 +4,20 @@ 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
### Fixed
- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist.
## [0.8.0] - 2026-09-28 ## [0.8.0] - 2026-09-28
### Added ### Added
+2 -1
View File
@@ -5,7 +5,7 @@ import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { createClient } from "redis"; import { createClient } from "redis";
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js"; import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js";
import { authPool } from "./lib/db.js"; import { authPool } from "./lib/db.js";
import { env } from "./lib/env.js"; import { env } from "./lib/env.js";
@@ -20,6 +20,7 @@ try {
} }
await retry("auth_migrations", runAuthMigrations); await retry("auth_migrations", runAuthMigrations);
await verifyUsersWithoutMailTransport();
await promoteAdminEmails(); await promoteAdminEmails();
const app = new Hono(); const app = new Hono();
+49 -14
View File
@@ -4,7 +4,7 @@ import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration"; import { getMigrations } from "better-auth/db/migration";
import { authPool } from "./db.js"; import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js"; import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js"; import { getBoolSetting } from "./settings.js";
import { env } from "./env.js"; import { env } from "./env.js";
@@ -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,
@@ -72,20 +84,29 @@ export const auth = betterAuth({
// Gates only the public sign-up endpoint. Users created through the // Gates only the public sign-up endpoint. Users created through the
// admin plugin or OAuth callbacks take different paths and pass. // admin plugin or OAuth callbacks take different paths and pass.
async before(user, ctx) { async before(user, ctx) {
if (ctx?.path !== "/sign-up/email") return; const data = { ...user };
const { rows } = await authPool.query<{ n: number }>( // Without a mail transport the verification mail can never arrive —
`select count(*)::int as n from "user"`, // mark every created account verified so nothing downstream nags
); // "unverified" on users nobody can verify.
if (rows[0].n === 0) { if (!(await hasMailTransport())) {
// Bootstrap: the first public signup becomes the instance admin. data.emailVerified = true;
return { data: { ...user, role: "admin" } };
} }
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED); if (ctx?.path === "/sign-up/email") {
if (!enabled) { const { rows } = await authPool.query<{ n: number }>(
throw new APIError("FORBIDDEN", { `select count(*)::int as n from "user"`,
message: "Sign-up is disabled on this instance.", );
}); if (rows[0].n === 0) {
// Bootstrap: the first public signup becomes the instance admin.
return { data: { ...data, role: "admin" } };
}
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
if (!enabled) {
throw new APIError("FORBIDDEN", {
message: "Sign-up is disabled on this instance.",
});
}
} }
return data.emailVerified === user.emailVerified ? undefined : { data };
}, },
}, },
}, },
@@ -94,6 +115,9 @@ export const auth = betterAuth({
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) { async sendVerificationEmail({ user, url }) {
// No transport at all → the mail can never arrive; the user was already
// marked verified at creation.
if (!(await hasMailTransport())) return;
// Verification is optional — a misconfigured transport must not fail // Verification is optional — a misconfigured transport must not fail
// sign-up or sign-in. // sign-up or sign-in.
try { try {
@@ -143,6 +167,17 @@ export async function runAuthMigrations() {
await migrations.runMigrations(); await migrations.runMigrations();
} }
/** 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() {
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. */ /** Bootstrap admins by email — run after migrations so the role column exists. */
export async function promoteAdminEmails() { export async function promoteAdminEmails() {
const emails = (env.AUTH_ADMIN_EMAILS ?? "") const emails = (env.AUTH_ADMIN_EMAILS ?? "")
+15
View File
@@ -30,6 +30,21 @@ async function resolveMailConfig() {
} as const; } as const;
} }
// 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> {
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: { export async function sendTransactionalEmail(input: {
to: string; to: string;
subject: string; subject: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.8.0", "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.0", "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.0", "version": "0.8.2",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [