29 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 361538aabc chore(release): v0.8.4 (#49)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:56:16 +02:00
Tomáš DvořákandDevin 5eec80bbca feat(frontend): auto-derive slugs from names on create forms (#48)
The org/project/bucket/collection create forms required typing a slug
by hand. While the slug field is untouched (empty or still equal to the
derived value), naming the entity fills its slug; a manual slug edit
stops the derivation and is sanitized to slug form as you type.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:53:27 +02:00
Tomáš DvořákandDevin 40c189c431 fix(install): stop exec from silencing the tty prompt (#47)
exec 9</dev/tty 2>/dev/null inside the if condition redirected the whole
shell's stderr to /dev/null permanently — the read -p prompt then wrote
into the void, leaving the installer blocked on an invisible question.
Replace with a plain </dev/tty redirect on read itself plus a quiet
true </dev/tty probe: the prompt displays, answers land, and no-tty
runs fall back to the default without error noise or the exec
redirect-failure shell-exit hazard.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:39:54 +02:00
Tomáš DvořákandDevin bc8b165ad9 fix(install): fail fast when GitHub fetch stalls (#46)
curl without timeouts hangs silently on a stalled connection — the
install looked dead between 'Fetching compose files' and the image
pulls. --connect-timeout/--max-time/--retry-all-errors turn it into a
clear error naming the file.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:14:15 +02:00
Tomáš Dvořák 61f0974c61 Merge pull request #45 from Dvorinka/release/v0.8.3
chore(release): v0.8.3
2026-09-28 20:40:17 +02:00
Tomas DvorakandDevin 769d04471f chore(release): v0.8.3
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:36:35 +02:00
Tomáš Dvořák da294aaa2b Merge pull request #44 from Dvorinka/fix/install-prompt-tty
fix(install): prompt on /dev/tty under curl|bash
2026-09-28 20:22:14 +02:00
Tomas DvorakandDevin 1226d44872 fix(install): prompt on /dev/tty so it works under curl|bash
stdin is the script pipe when run as `curl ... | bash`, so the -t 0
check silently skipped the domain question. Read from the controlling
terminal instead; no tty (CI/cron) still defaults to localhost.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:19:36 +02:00
Tomáš Dvořák ac4ca9a3ae Merge pull request #43 from Dvorinka/feat/install-public-url
feat(install): ask for the public URL on fresh installs
2026-09-28 19:34:27 +02:00
Tomas DvorakandDevin 597db3287e feat(install): ask for the public URL, wire it into .env
install.sh now asks one question on fresh installs — the URL the app is
opened on. Accepts https://primora.example.com (TLS at the user's proxy),
primora.example.com, or 192.168.1.50:8085; the port becomes NGINX_PORT.
The answer rewrites every public URL var and COOKIE_DOMAIN for real DNS
names. PUBLIC_URL env keeps it non-interactive; legacy DOMAIN/NGINX_PORT
still work; reruns over an existing .env skip the question and display
the URL from the file.

Also trust both URL schemes on the serving host in trustedOrigins —
external TLS termination makes nginx report http while browsers arrive
over https.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 19:31:54 +02:00
Tomáš Dvořák bbc57f9c0d Merge pull request #42 from Dvorinka/fix/sbom-permissions
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix(release): contents:write for SBOM asset upload
2026-09-28 16:44:05 +02:00
Tomas DvorakandDevin 9ab9b550d1 fix(release): contents:write for SBOM asset upload
The sbom job's `permissions: packages: read` replaces the default token
set, so `gh release upload` got 403 Resource not accessible. Grant
contents:write alongside packages:read.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:41:17 +02:00
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
Tomas DvorakandDevin 61e8c696c9 fix: build release images on native arch runners
arm64 npm ci under QEMU SIGILLs on current node:20-alpine layers
(uncaught target signal 4) and wedges the build step — the v0.8.0
release stalled there twice. Build each platform natively and join
the arch tags with a manifests job.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 13:37:56 +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
15 changed files with 334 additions and 56 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
# 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=
MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit
SMTP_HOST=
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
+48 -13
View File
@@ -59,17 +59,20 @@ jobs:
fi
images:
name: Build + push ${{ matrix.app }}
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
# Native runners per arch — QEMU emulation SIGILLs on the current
# node:20-alpine arm64 layers (uncaught target signal 4) and wedges
# the build step indefinitely.
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
app: [backend, auth, frontend]
platform: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
@@ -84,13 +87,38 @@ jobs:
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/${{ matrix.platform }}
push: true
tags: |
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
# Joins the per-arch images into the real version + latest tags.
manifests:
name: Image manifests
needs: [verify, images]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create multi-arch manifests
env:
VERSION: ${{ needs.verify.outputs.version }}
run: |
set -e
for app in backend auth frontend; do
img="ghcr.io/${GHCR_OWNER}/primora-${app}"
docker buildx imagetools create \
-t "$img:$VERSION" -t "$img:latest" \
"$img:$VERSION-amd64" "$img:$VERSION-arm64"
done
cli-binaries:
name: Compile ${{ matrix.name }}
@@ -192,7 +220,7 @@ jobs:
release:
name: GitHub Release
needs: [verify, images, cli-binaries, desktop, npm-packages]
needs: [verify, manifests, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
env:
@@ -299,28 +327,35 @@ jobs:
runs-on: ubuntu-latest
permissions:
packages: read
# contents: write — the job uploads SBOM assets onto the release.
contents: write
env:
VERSION: ${{ needs.verify.outputs.version }}
steps:
- 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
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# anchore/sbom-action does not create the output directory.
- run: mkdir -p dist-release
- uses: anchore/sbom-action@v0
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
output-file: dist-release/sbom-backend.cdx.json
- uses: anchore/sbom-action@v0
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
output-file: dist-release/sbom-auth.cdx.json
- uses: anchore/sbom-action@v0
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
output-file: dist-release/sbom-frontend.cdx.json
- uses: anchore/sbom-action@v0
+2
View File
@@ -39,3 +39,5 @@ infra/mailpit
# Generated code-graph artifacts
graphify-out/
.playwright-mcp/
# Local brag/launch-video output
brag-output/
+36
View File
@@ -4,6 +4,41 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
## [Unreleased]
## [0.8.4] - 2026-09-28
### Added
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
### Fixed
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
## [0.8.3] - 2026-09-28
### Added
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
### Fixed
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
## [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
### Added
@@ -55,6 +90,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
- **Release image builds moved off QEMU** — arm64 `npm ci` under `docker/setup-qemu-action` SIGILLed on current `node:20-alpine` layers (`uncaught target signal 4`) and wedged the build step for over an hour. Images now build per-arch on native runners (`ubuntu-24.04-arm` for arm64) and a `manifests` job joins them into the multi-arch `:<version>`/`:latest` tags.
## [0.7.1] - 2026-09-23
+3 -1
View File
@@ -77,7 +77,9 @@ Prerequisites: Docker with the Compose plugin.
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
```
Installs into `./primora`, generates secrets, and starts the stack on port 80.
Installs into `./primora`, asks one question — the URL you'll open the app on
(domain, IP, optional port) — generates secrets, and starts the stack.
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
Or from a clone:
```bash
+2 -1
View File
@@ -5,7 +5,7 @@ import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
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 { env } from "./lib/env.js";
@@ -20,6 +20,7 @@ try {
}
await retry("auth_migrations", runAuthMigrations);
await verifyUsersWithoutMailTransport();
await promoteAdminEmails();
const app = new Hono();
+52 -14
View File
@@ -4,7 +4,7 @@ import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration";
import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js";
import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js";
import { env } from "./env.js";
@@ -53,7 +53,22 @@ 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}`);
// TLS often terminates at an external proxy — nginx then reports http.
// Both schemes on the serving host are legitimately this instance.
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
}
return origins;
},
database: authPool,
emailAndPassword: {
enabled: true,
@@ -72,20 +87,29 @@ export const auth = betterAuth({
// Gates only the public sign-up endpoint. Users created through the
// admin plugin or OAuth callbacks take different paths and pass.
async before(user, ctx) {
if (ctx?.path !== "/sign-up/email") return;
const { rows } = await authPool.query<{ n: number }>(
`select count(*)::int as n from "user"`,
);
if (rows[0].n === 0) {
// Bootstrap: the first public signup becomes the instance admin.
return { data: { ...user, role: "admin" } };
const data = { ...user };
// Without a mail transport the verification mail can never arrive —
// mark every created account verified so nothing downstream nags
// "unverified" on users nobody can verify.
if (!(await hasMailTransport())) {
data.emailVerified = true;
}
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
if (!enabled) {
throw new APIError("FORBIDDEN", {
message: "Sign-up is disabled on this instance.",
});
if (ctx?.path === "/sign-up/email") {
const { rows } = await authPool.query<{ n: number }>(
`select count(*)::int as n from "user"`,
);
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 +118,9 @@ export const auth = betterAuth({
sendOnSignUp: true,
autoSignInAfterVerification: true,
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
// sign-up or sign-in.
try {
@@ -143,6 +170,17 @@ export async function runAuthMigrations() {
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. */
export async function promoteAdminEmails() {
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
+15
View File
@@ -30,6 +30,21 @@ async function resolveMailConfig() {
} 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: {
to: string;
subject: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/desktop",
"version": "0.8.0",
"version": "0.8.4",
"private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Primora",
"version": "0.8.0",
"version": "0.8.4",
"identifier": "dev.tdvorak.primora",
"build": {
"frontendDist": "../ui"
+19 -7
View File
@@ -28,6 +28,7 @@ import { Show, createEffect, createMemo, createResource, createSignal, onCleanup
import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import { applySlugField } from "./lib/slug";
import {
AgentSetupModal,
AppShell,
@@ -735,11 +736,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"));
@@ -1659,14 +1671,14 @@ export default function App() {
label="Organization name"
placeholder="Acme Corporation"
value={organizationInput().name}
onInput={(e) => setOrganizationInput((c) => ({ ...c, name: e.currentTarget.value }))}
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "name", e.currentTarget.value))}
required
/>
<Input
label="Slug"
placeholder="acme"
value={organizationInput().slug}
onInput={(e) => setOrganizationInput((c) => ({ ...c, slug: e.currentTarget.value }))}
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "slug", e.currentTarget.value))}
required
/>
<button type="submit" class="btn btn-primary w-full" disabled={workspacePending()}>
@@ -1712,7 +1724,7 @@ export default function App() {
projectMessage={projectMessage()}
projectPending={projectPending()}
canUpdateProject={canUpdateProject()}
onProjectInputChange={(field, value) => setProjectInput((c) => ({ ...c, [field]: value }))}
onProjectInputChange={(field, value) => setProjectInput((c) => applySlugField(c, field, value))}
onProjectEditInputChange={(field, value) => setProjectEditInput((c) => ({ ...c, [field]: value }))}
onCreateProject={createProject}
onUpdateProject={updateActiveProject}
@@ -1811,7 +1823,7 @@ export default function App() {
previewLoading={objectPreview.loading}
renameObjectKey={renameObjectKeyInput()}
moveDestinationBucketID={moveDestinationBucketID()}
onBucketInputChange={(field, value) => setBucketInput((c) => ({ ...c, [field]: value }))}
onBucketInputChange={(field, value) => setBucketInput((c) => applySlugField(c, field, value))}
onBucketEditInputChange={(field, value) => setBucketEditInput((c) => ({ ...c, [field]: value }))}
onCreateBucket={createBucket}
onUpdateBucket={updateSelectedBucket}
@@ -1848,7 +1860,7 @@ export default function App() {
collectionPending={collectionPending()}
documentPending={documentPending()}
canUpdate={canUpdateProject()}
onCollectionInputChange={(field, value) => setCollectionInput((c) => ({ ...c, [field]: value }))}
onCollectionInputChange={(field, value) => setCollectionInput((c) => applySlugField(c, field, value))}
onCreateCollection={createCollection}
onDeleteCollection={deleteCollection}
onSelectCollection={setSelectedCollectionID}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { applySlugField, slugify } from "../slug";
describe("slugify", () => {
it("derives lowercase hyphenated slugs", () => {
expect(slugify("TDvorak")).toBe("tdvorak");
expect(slugify("Acme Corporation")).toBe("acme-corporation");
expect(slugify(" My_App!! v2 ")).toBe("my-app-v2");
});
it("strips accents and non-ascii characters", () => {
expect(slugify("Zürich — Héllo")).toBe("zurich-hello");
expect(slugify("日本語")).toBe("");
});
});
describe("applySlugField", () => {
const form = { name: "", slug: "", description: "" };
it("fills the slug while it is untouched", () => {
const next = applySlugField(form, "name", "My Org");
expect(next.slug).toBe("my-org");
// keeps following name edits
expect(applySlugField(next, "name", "Renamed Org").slug).toBe("renamed-org");
});
it("stops deriving once the slug was hand-edited", () => {
const custom = applySlugField(applySlugField(form, "name", "My Org"), "slug", "custom-slug");
expect(applySlugField(custom, "name", "New Name").slug).toBe("custom-slug");
});
it("resumes deriving if the slug is cleared", () => {
const cleared = applySlugField(applySlugField(form, "name", "My Org"), "slug", "");
expect(applySlugField(cleared, "name", "Other").slug).toBe("other");
});
it("sanitizes manual slug edits", () => {
expect(applySlugField(form, "slug", "Not A Slug!").slug).toBe("not-a-slug");
});
it("passes through unrelated fields", () => {
expect(applySlugField(form, "description", "hi").description).toBe("hi");
});
});
+27
View File
@@ -0,0 +1,27 @@
export function slugify(value: string): string {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}
/**
* Applies a field edit to a {name, slug, ...} form. While the slug is
* untouched — empty or still equal to the derived value — renaming the
* entity re-derives its slug. Explicit slug edits are sanitized in place.
*/
export function applySlugField<T extends { name: string; slug: string }>(
current: T,
field: string,
value: string,
): T {
if (field === "slug") return { ...current, slug: slugify(value) };
if (field === "name" && (current.slug === "" || current.slug === slugify(current.name))) {
return { ...current, name: value, slug: slugify(value) };
}
return { ...current, [field]: value };
}
+77 -16
View File
@@ -3,8 +3,14 @@
#
# curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
#
# Installs into ./primora (override with PRIMORA_DIR). Non-interactive:
# DOMAIN=example.com NGINX_PORT=8080 bash install.sh
# Installs into ./primora (override with PRIMORA_DIR). On a fresh install,
# interactive shells get one question — the URL the app will be opened on
# (domain or IP, optional port). Non-interactive:
# PUBLIC_URL=https://primora.example.com bash install.sh
# DOMAIN=192.168.1.50 NGINX_PORT=8085 bash install.sh (legacy form)
#
# https URLs mean TLS terminates at your own proxy in front of Primora's
# bundled nginx (which always speaks http on NGINX_PORT).
#
# Pulls prebuilt GHCR images when available; otherwise downloads the source
# tarball and builds locally. Either way the result is the same compose stack.
@@ -14,8 +20,8 @@ set -euo pipefail
REPO="Dvorinka/Primora"
REF="${PRIMORA_REF:-master}"
DIR="${PRIMORA_DIR:-primora}"
DOMAIN="${DOMAIN:-localhost}"
NGINX_PORT="${NGINX_PORT:-80}"
PUBLIC_URL="${PUBLIC_URL:-}"
info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
@@ -32,12 +38,62 @@ mkdir -p "$DIR/infra/nginx"
cd "$DIR"
# The compose stack needs exactly these three files from the repo.
# Timeouts + retries: a stalled connection must fail loudly, not hang.
info "Fetching compose files ($REF)"
for f in docker-compose.yml .env.example infra/nginx/default.conf; do
curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f"
curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-all-errors \
"https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f" \
|| die "could not fetch $f — check connectivity to raw.githubusercontent.com"
done
if [ ! -f .env ]; then
if [ -f .env ]; then
info "Existing .env kept"
# Display URL comes from the file — answers here would change nothing.
PUBLIC_URL="$(grep -E '^VITE_APP_URL=' .env | tail -1 | cut -d= -f2- || true)"
[ -z "$PUBLIC_URL" ] && PUBLIC_URL="http://localhost"
else
# Public URL — the single question. Env wins, then prompt, then default.
if [ -z "$PUBLIC_URL" ] && [ -n "${DOMAIN:-}" ]; then
PUBLIC_URL="http://${DOMAIN}"
[ "$NGINX_PORT" != "80" ] && PUBLIC_URL="$PUBLIC_URL:$NGINX_PORT"
fi
if [ -z "$PUBLIC_URL" ]; then
DEFAULT_URL="http://localhost"
[ "$NGINX_PORT" != "80" ] && DEFAULT_URL="http://localhost:$NGINX_PORT"
# Under `curl | bash` stdin is the script pipe — ask on the controlling
# terminal instead. `true </dev/tty` probes the open quietly; read's own
# redirect fails non-fatally (default applies) where there is no tty.
# Never `exec ... 2>/dev/null` here — exec redirections persist and would
# silence stderr, including the read prompt below, for the whole script.
if true 2>/dev/null </dev/tty; then
read -rp "URL where you'll open Primora (domain or IP, e.g. https://primora.example.com) [$DEFAULT_URL]: " PUBLIC_URL </dev/tty || true
fi
PUBLIC_URL="${PUBLIC_URL:-$DEFAULT_URL}"
fi
# Normalize: default scheme http, strip trailing slash.
case "$PUBLIC_URL" in
http://*|https://*) ;;
*) PUBLIC_URL="http://$PUBLIC_URL" ;;
esac
PUBLIC_URL="${PUBLIC_URL%/}"
# Split host[:port]; URL paths aren't supported — nginx mounts auth at /auth.
REST="${PUBLIC_URL#*://}"
HOSTPORT="${REST%%/*}"
[ "$REST" != "$HOSTPORT" ] && die "URL paths are not supported — use a domain or port instead: $PUBLIC_URL"
if [[ "$HOSTPORT" == *:* ]]; then
HOST="${HOSTPORT%%:*}"
PORT="${HOSTPORT##*:}"
else
HOST="$HOSTPORT"; PORT=""
fi
[ -z "$HOST" ] && die "could not parse a host from: $PUBLIC_URL"
if [ -n "$PORT" ]; then
[[ "$PORT" =~ ^[0-9]+$ ]] || die "invalid port in: $PUBLIC_URL"
NGINX_PORT="$PORT"
fi
info "Generating .env with random secrets"
cp .env.example .env
@@ -53,12 +109,15 @@ if [ ! -f .env ]; then
"${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
"${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env
if [ "$DOMAIN" != "localhost" ]; then
"${SED[@]}" "s|http://localhost|http://$DOMAIN|g" .env
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$DOMAIN|" .env
# Every public URL var (VITE_APP_URL, BETTER_AUTH_URL, AUTH_BASE_URL,
# VITE_AUTH_BASE_URL, VITE_API_BASE_URL, BACKEND_PUBLIC_URL) is built on the
# http://localhost prefix in .env.example — one rewrite covers them all.
"${SED[@]}" "s|http://localhost|$PUBLIC_URL|g" .env
# Cookie domains only apply to real DNS names — never IPs or localhost.
if [ "$HOST" != "localhost" ] && ! [[ "$HOST" =~ ^[0-9.]+$ ]]; then
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$HOST|" .env
fi
else
info "Existing .env kept"
fi
# Compose v5 skips `pull` for services that declare build:, so fetch the
@@ -75,15 +134,17 @@ if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
else
# GHCR packages are private until first published release — build from source.
info "Prebuilt images unavailable; building from source"
curl -fsSL "https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
curl -fsSL --connect-timeout 10 --max-time 120 --retry 3 --retry-delay 1 --retry-all-errors \
"https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
docker compose up -d --build
fi
DISPLAY_URL="http://$DOMAIN"
[ "$NGINX_PORT" != "80" ] && DISPLAY_URL="$DISPLAY_URL:$NGINX_PORT"
info "Primora is starting"
echo " Dashboard: $DISPLAY_URL"
echo " Health: $DISPLAY_URL/api/v1/health/liveness"
echo " Dashboard: $PUBLIC_URL"
echo " Health: $PUBLIC_URL/api/v1/health/liveness"
echo " Logs: cd $DIR && docker compose logs -f"
echo " Stop: cd $DIR && docker compose down"
if [[ "$PUBLIC_URL" == https://* ]]; then
echo
echo " Primora listens on http port $NGINX_PORT — point your TLS proxy there."
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "primora",
"version": "0.8.0",
"version": "0.8.4",
"private": true,
"packageManager": "[email protected]",
"workspaces": [