16 Commits
Author SHA1 Message Date
Tomas DvorakandDevin 112cf84362 chore: release v0.7.1, auto-release on merge
Release workflow now also fires on workflow_run when CI succeeds on
master; verify job skips unless package.json version lacks a matching
tag, so merges only release on a version bump. Tag pushes unchanged.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:12:24 +02:00
Tomáš Dvořák 61ede5ee99 Merge pull request #13 from Dvorinka/fix/selfhosted-email-optional
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: self-hosted auth without mail transport, password-manager-friendly login
2026-09-22 19:50:30 +02:00
Tomas DvorakandDevin 2fbde04563 fix: don't require email infra on self-hosted, improve login form detection
- sendTransactionalEmail no-ops (with a warning) when no mail transport is
  configured instead of throwing — sign-up 500'd on SMTP-less instances
- sendVerificationEmail catches transport failures so a broken SMTP can't
  block account creation; verification stays optional (requireEmailVerification
  was never set)
- login form gains id/name attributes and label for/id pairing so password
  managers (Proton Pass et al.) detect the fields; email input uses
  autocomplete=username

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:46:14 +02:00
Tomáš Dvořák 9c118d41c2 Merge pull request #12 from Dvorinka/feat/landing-page
feat: add static landing page
2026-09-22 19:38:16 +02:00
Tomas DvorakandDevin c0c5e1cfcb chore: gitignore vercel link metadata in landing/
Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:19:40 +02:00
Tomas DvorakandDevin 28b10eee2b feat: add static landing page
Self-contained index.html under landing/ — no build step, deploys as a
plain static site (Vercel output dir = landing/). Uses the app's design
tokens (zinc dark, vermilion accent, Geist/Geist Mono vendored as woff2),
real screenshots from docs/, and the README feature/architecture facts.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:14:28 +02:00
Tomáš Dvořák 23868ef7c9 Merge pull request #11 from Dvorinka/fix/installer-strip-build-blocks
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: strip build blocks in installer prebuilt path
2026-09-21 22:31:36 +02:00
Tomas DvorakandDevin 10c667dffd fix: strip build blocks in installer prebuilt path
The installer's GHCR path ships a compose file that still declares
build: contexts pointing at apps/*/Dockerfile, which it never
downloads. Any later `compose build` or `up --build` fails with
"lstat apps: no such file or directory". Delete the build blocks
after a successful prebuilt pull so the shipped file matches what
is actually runnable.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 22:25:32 +02:00
Tomáš Dvořák 11aa82b459 Merge pull request #10 from Dvorinka/release/v0.7.0
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
chore: release v0.7.0
2026-09-21 17:58:18 +02:00
Tomas DvorakandDevin 9658e167ba chore: release v0.7.0
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:53:47 +02:00
Tomáš Dvořák e71db32885 Merge pull request #9 from Dvorinka/feat/instance-admin
feat: instance administration, bootstrap admin, managed-tier OAuth gate
2026-09-21 17:53:03 +02:00
Tomas DvorakandDevin c6166de886 feat: instance administration, bootstrap admin, and managed-tier OAuth gate
Self-hosted instances are now email/password only — OAuth providers
(GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true,
reserved for the future hosted tier.

Sign-up lifecycle: the first public sign-up becomes the instance admin; after
bootstrap, sign-up closes by default. A better-auth databaseHook gates only
/sign-up/email, so admin-created users and OAuth flows bypass it. The login
page reads GET /instance/public to hide the sign-up tab, show the bootstrap
notice, and render only the providers the instance offers.

Instance settings live in core.settings (migration 00010) and resolve
in-app > env > built-in default, cached 15s. Platform admins (better-auth
user.role) manage sign-up, mail transport, and rate limits via
GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret
values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never
returned by the API, and decrypted by the auth service at send time.

Auth service reads the same settings table for the sign-up gate and mail
config, so in-app changes take effect without a restart.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:46:47 +02:00
Tomáš Dvořák 1214108d6b Merge pull request #8 from Dvorinka/docs/readme-cleanup
docs: slim README, move detail to per-app and contributor docs
2026-09-21 16:49:00 +02:00
Tomas DvorakandDevin 4f66c72121 docs: slim README, move detail into per-app and contributor docs
README down to the Containr skeleton: header, three-screenshot row
(rest behind <details>), features, architecture, quick start, links.
Dropped tables and sections already covered by QUICK_START.md.
Native dev commands and repo layout moved to CONTRIBUTING.md;
CLI/MCP usage moved to new per-app READMEs.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:46:32 +02:00
Tomáš Dvořák d3dc84028d Merge pull request #7 from Dvorinka/docs/readme-screenshots
docs: README refresh with logo and app screenshots
2026-09-21 16:19:12 +02:00
Tomas DvorakandDevin 591dedab2f docs: restructure README, add logo and app screenshots
Containr-style layout: centered mark, tagline, nav, CI/release/license
badges. New screenshots section with six captures from demo mode
(overview, storage, telemetry, collections, automation, audit).
Configuration and port tables added; existing install/dev/CLI content
preserved.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:16:38 +02:00
65 changed files with 2663 additions and 266 deletions

No files matched your search

+11
View File
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
BETTER_AUTH_URL=http://localhost/auth BETTER_AUTH_URL=http://localhost/auth
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel) # Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
AUTH_ADMIN_EMAILS= AUTH_ADMIN_EMAILS=
# The first public sign-up always becomes the instance admin. With no users the
# login page shows only the sign-up form. After bootstrap, public sign-up is
# closed unless SIGNUP_ENABLED=true or the admin enables it in-app
# (Settings → Instance). In-app values override this file.
SIGNUP_ENABLED=false
# Managed tier flag — OAuth providers activate only when this is "true".
# Self-hosted instances are email/password regardless of the keys below.
PRIMORA_MANAGED=false
COOKIE_DOMAIN=localhost COOKIE_DOMAIN=localhost
FRONTEND_PORT=3000 FRONTEND_PORT=3000
@@ -42,6 +50,8 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080 BACKEND_INTERNAL_URL=http://backend:8080
BACKEND_STORAGE_ROOT=/data/storage BACKEND_STORAGE_ROOT=/data/storage
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here.
RESEND_API_KEY= RESEND_API_KEY=
MAIL_FROM=Primora <[email protected]> MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit SMTP_HOST=mailpit
@@ -50,6 +60,7 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
+54 -8
View File
@@ -1,25 +1,54 @@
name: Release name: Release
# Triggered by semver tags: git tag v0.3.0 && git push --tags # Triggered two ways:
# Tag must match the root package.json version. # 1. Semver tags: git tag v0.3.0 && git push --tags (must match package.json).
# 2. workflow_run: after the CI workflow succeeds on master, the version in
# root package.json is released automatically if no matching tag exists.
on: on:
push: push:
tags: ["v*.*.*"] tags: ["v*.*.*"]
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
permissions: permissions:
contents: write contents: write
packages: write packages: write
env:
VERSION: ${{ github.ref_name }}
jobs: jobs:
verify: verify:
name: Verify tag name: Verify version
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.check.outputs.version }}
needed: ${{ steps.check.outputs.needed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Resolve version + decide whether to release
id: check
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then
echo "CI did not succeed — skipping release."
echo "needed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="v$(node -p "require('./package.json').version")"
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null 2>&1; then
echo "$VERSION already released — nothing to do."
echo "needed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
else
VERSION="${{ github.ref_name }}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "needed=true" >> "$GITHUB_OUTPUT"
- name: Check tag matches package versions - name: Check tag matches package versions
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: | run: |
PKG=$(node -p "require('./package.json').version") PKG=$(node -p "require('./package.json').version")
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version") DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
@@ -32,6 +61,7 @@ jobs:
images: images:
name: Build + push ${{ matrix.app }} name: Build + push ${{ matrix.app }}
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -57,7 +87,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ env.VERSION }} ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }} cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }} cache-to: type=gha,mode=max,scope=${{ matrix.app }}
@@ -65,6 +95,7 @@ jobs:
cli-binaries: cli-binaries:
name: Compile ${{ matrix.name }} name: Compile ${{ matrix.name }}
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -94,6 +125,7 @@ jobs:
desktop: desktop:
name: Desktop bundle (${{ matrix.name }}) name: Desktop bundle (${{ matrix.name }})
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
@@ -130,6 +162,7 @@ jobs:
npm-packages: npm-packages:
name: Pack npm tarballs name: Pack npm tarballs
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -151,8 +184,11 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: [images, cli-binaries, desktop, npm-packages] needs: [verify, images, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
VERSION: ${{ needs.verify.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# build-push-action uploads "*.dockerbuild" metadata artifacts that # build-push-action uploads "*.dockerbuild" metadata artifacts that
@@ -171,6 +207,16 @@ jobs:
with: with:
name: npm-tarballs name: npm-tarballs
path: dist-release path: dist-release
# workflow_run runs leave the release untagged — create it so the
# GitHub Release points at a real tag. GITHUB_TOKEN pushes do not
# re-trigger workflows, so this cannot recurse.
- name: Tag release
if: github.event_name == 'workflow_run'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$VERSION"
git push origin "$VERSION"
- name: Extract changelog section - name: Extract changelog section
run: | run: |
awk -v ver="${VERSION#v}" ' awk -v ver="${VERSION#v}" '
+30
View File
@@ -2,6 +2,36 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [0.7.1] - 2026-09-23
### Fixed
- **Self-hosted sign-up no longer requires mail infrastructure** — `sendTransactionalEmail` warned-and-returned when no transport was configured instead of throwing, so SMTP-less instances 500'd on sign-up. `sendVerificationEmail` now also catches transport failures; a broken SMTP cannot block account creation (verification stays optional).
- **Password managers detect the login form** — the email/password fields gained `id`/`name` attributes and `label for`/`id` pairing, and the email input uses `autocomplete="username"` (Proton Pass et al.).
- **Installer prebuilt path strips build blocks** — `install.sh` previously kept `*-build` compose blocks when pulling prebuilt GHCR images.
### Added
- **Static landing page** (`landing/`) — self-contained marketing site with bundled Geist fonts and product screenshots; deployed separately from the app artifacts.
### Infrastructure
- **Automatic releases on merge** — merges to `master` whose root `package.json` version has no matching `v*` tag now trigger the full release pipeline (GHCR images, CLI binaries, desktop bundles, npm tarballs, GitHub Release). Tag pushes still work as before.
## [0.7.0] - 2026-09-21
### Changed
- **Public sign-up closes by default after the first account.** On upgrade, existing instances keep their users but new public sign-ups are rejected until an admin enables them (Settings → Instance or `SIGNUP_ENABLED=true`). The oldest existing account is not automatically promoted — set `AUTH_ADMIN_EMAILS` or update `user.role` to `admin` manually if no platform admin exists.
- **OAuth providers require `PRIMORA_MANAGED=true`.** Self-hosted deployments are email/password only; GitHub/Google/Discord/Microsoft env keys are ignored without the flag.
### Added
- **Instance administration** — `core.settings` (migration 00010) stores in-app overrides that resolve ahead of `.env` and built-in defaults. New endpoints: `GET /instance/public` (unauthenticated login-page state), `GET/PUT/DELETE /instance/settings[/key]` (platform admin only — better-auth `user.role`, not org role). Manageable keys cover public sign-up, mail transport (Resend or SMTP), and API/user rate limits. Secrets are AES-256-GCM encrypted at rest, never returned by the API, and decryptable by the auth service via the shared `PRIMORA_ENCRYPTION_KEY`.
- **Bootstrap + sign-up gating** — the first public sign-up becomes the instance admin; afterwards public sign-up is closed unless `SIGNUP_ENABLED=true` or the admin reopens it in Settings → Instance. `databaseHooks.user.create.before` gates only `/sign-up/email`, so admin-created users and OAuth flows bypass it.
- **Managed-tier OAuth gate** — `PRIMORA_MANAGED=true` is now required for GitHub/Google/Discord/Microsoft sign-in; self-hosted instances are email/password only. The login page hides the sign-up tab and social buttons according to `GET /instance/public`.
- **Settings → Instance tab** — platform admins edit sign-up, mail, and rate-limit settings in-app with source badges (in-app/env/default) and per-key reset.
## [0.6.0] - 2026-09-21 ## [0.6.0] - 2026-09-21
### Added ### Added
+35 -3
View File
@@ -9,9 +9,24 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
For native development run only the infrastructure in Docker For native development run only the infrastructure in Docker and start the
(`docker compose up -d postgres dragonfly mailpit`) and start the three three services yourself:
services yourself.
```bash
docker compose up -d postgres dragonfly mailpit # infrastructure only
cd apps/backend && go run ./cmd/server # API on :8080
npm run dev:auth # auth on :3001
npm run dev:frontend # dashboard (Vite)
```
Point the frontend at the stack via `apps/frontend/.env.local`:
```env
VITE_API_BASE_URL=http://localhost/api/v1
VITE_AUTH_BASE_URL=http://localhost/auth
```
Or skip the backend entirely: `?demo=true` runs a fully client-side workspace.
## Workflow ## Workflow
@@ -29,6 +44,23 @@ services yourself.
If you changed `apps/backend/openapi/openapi.yaml`, run If you changed `apps/backend/openapi/openapi.yaml`, run
`npm run generate:client`. Commit the regenerated output with your change. `npm run generate:client`. Commit the regenerated output with your change.
## Repository layout
```
apps/
backend/ Go + Gin API, sqlc, migrations, object storage
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
frontend/ SolidJS + Tailwind dashboard
cli/ `primora` CLI - login, context, projects, buckets, objects, keys, audit
mcp/ `@primora/mcp` - MCP stdio server exposing `primora_*` tools
desktop/ Tauri 2 shell - connects to a deployment URL, tray presence
packages/
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
shared-types/
infra/nginx/ Reverse proxy config
scripts/ setup.sh, verify-production-ready.sh
```
## Conventions ## Conventions
- **Generated code is never hand-edited** — `packages/api-client/src/generated` - **Generated code is never hand-edited** — `packages/api-client/src/generated`
+24 -2
View File
@@ -61,10 +61,32 @@ The important ones:
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection | | `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
| `DRAGONFLY_URL` | Cache/rate-limit store | | `DRAGONFLY_URL` | Cache/rate-limit store |
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) | | `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
| `GITHUB_CLIENT_ID` / `GOOGLE_CLIENT_ID` / `DISCORD_*` / `MICROSOFT_*` | Optional OAuth providers | | `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables | | `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
| `VITE_DEMO_MODE` | Enable the client-side demo workspace | | `VITE_DEMO_MODE` | Enable the client-side demo workspace |
### Instance administration
The **first public sign-up becomes the instance admin** — on a fresh database the
login page only offers sign-up. After that, public sign-up is closed and the
admin can reopen it in-app.
Admins get a **Settings → Instance** tab that manages the instance without a
restart: public sign-up, mail transport (Resend or SMTP), and rate limits.
Every setting resolves in the order **in-app value → `.env` → built-in
default**; resetting a key reverts to env/default. Secrets (SMTP password,
Resend key) are stored AES-256-GCM-encrypted and never displayed back.
API equivalents:
```bash
GET /api/v1/instance/public # unauthenticated — login page state
GET /api/v1/instance/settings # platform admin
PUT /api/v1/instance/settings/{key} # platform admin — {"value": ...}
DELETE /api/v1/instance/settings/{key} # platform admin — revert to env/default
```
## Verification ## Verification
```bash ```bash
+80 -128
View File
@@ -1,6 +1,60 @@
# Primora <p align="center">
<img src="./primora.svg" alt="Primora" width="120">
</p>
A self-hosted backend platform for building products — auth, Postgres-backed APIs, object storage, JSON document collections, API keys, and a full audit log behind one dashboard. Inspired by the UX of Appwrite and Supabase, with no hosted tier: your data stays on your hardware. <h1 align="center">Primora</h1>
<p align="center">
Self-hosted backend platform for building products.<br>
Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="QUICK_START.md">Documentation</a> ·
<a href="https://github.com/Dvorinka/Primora/releases">Releases</a> ·
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Dvorinka/Primora/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Primora" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Primora" alt="License"></a>
</p>
## What is Primora?
Primora is a self-hosted backend platform: organizations, projects, auth,
S3-style object storage, JSON document collections, scoped API keys, and a
full audit log behind one dashboard. Inspired by the UX of Appwrite and
Supabase, with no hosted tier - your data stays on your hardware.
## Screenshots
| Overview | Storage | Telemetry |
|:-:|:-:|:-:|
| ![Project overview](docs/screenshots/overview.png) | ![Object storage](docs/screenshots/storage.png) | ![Telemetry and component health](docs/screenshots/telemetry.png) |
<details>
<summary>More screenshots</summary>
| Collections | Automation | Audit log |
|:-:|:-:|:-:|
| ![JSON document collections](docs/screenshots/collections.png) | ![Scheduled jobs and live events](docs/screenshots/automation.png) | ![Audit log with export](docs/screenshots/audit.png) |
</details>
## Features
- **Organizations & projects** - workspaces with scoped org and project roles.
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
- **Storage** - S3-style buckets on your local filesystem, public or private.
- **Collections** - schema-flexible JSON documents in Postgres JSONB.
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
- **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
## Architecture
``` ```
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite) Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
@@ -10,153 +64,51 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
──▶ Local filesystem object storage ──▶ Local filesystem object storage
``` ```
## What you get ## Quick Start
- **Organizations & projects** — top-level workspaces containing projects, members, and scoped roles (`owner`/`admin`/`member` org roles, `admin`/`developer`/`viewer` project roles).
- **Auth** — email/password plus optional GitHub, Google, Discord, and Microsoft OAuth via Better Auth. JWTs minted by the auth service are verified by the Go API against JWKS.
- **Storage** — S3-style buckets and objects backed by your local filesystem, with public/private visibility and downloadable URLs.
- **Collections** — schema-flexible JSON documents stored in Postgres JSONB.
- **API keys** — `prm_<prefix>_<secret>` credentials; secrets are shown once.
- **Audit log** — every mutating request recorded with actor, resource, request ID, and timestamp; CSV/JSON export from the dashboard.
- **Generated client** — the TypeScript client is generated from `apps/backend/openapi/openapi.yaml`, so the API contract is the source of truth.
- **Demo mode** — a fully client-side workspace (`?demo=true` or `VITE_DEMO_MODE=true`) for trying the UI without a backend.
## Install
Prerequisites: Docker with the Compose plugin. Prerequisites: Docker with the Compose plugin.
### One-liner (no clone)
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash 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`, generates secrets, and starts the stack on port 80.
Non-interactive — configure with env vars: Or from a clone:
```bash
DOMAIN=example.com NGINX_PORT=8080 PRIMORA_DIR=/opt/primora bash install.sh
```
The installer pulls prebuilt images from GHCR when they are published;
otherwise it downloads the source tarball and builds locally.
### From a clone
```bash ```bash
git clone https://github.com/Dvorinka/Primora.git && cd Primora git clone https://github.com/Dvorinka/Primora.git && cd Primora
./scripts/setup.sh # creates .env, generates secrets, starts the stack ./scripts/setup.sh # creates .env, generates secrets, starts the stack
``` ```
or manually: Then open `http://localhost` - or append `?demo=true` to explore a seeded
workspace without signing up. For prebuilt images, native development, port
conflicts, and troubleshooting see [QUICK_START.md](QUICK_START.md).
```bash ## Configuration
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
docker compose up -d # builds the three app images from source
```
To run published images instead of building: All configuration lives in `.env` - see `.env.example` for the annotated list
and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
```bash ## Ecosystem
docker pull ghcr.io/dvorinka/primora-{backend,auth,frontend}:latest
docker compose up -d --no-build
```
Then open `http://localhost`. For local email capture, add the dev overlay - **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal.
(`-f docker-compose.dev.yml`) and Mailpit is at `http://localhost/mailpit/`. - **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence.
If ports `5432` or `6379` are already in use on your machine, set `POSTGRES_PORT` and `DRAGONFLY_PORT` in `.env` — only the host bindings change. - **PWA** - the dashboard is installable on mobile and desktop browsers.
### Useful commands
```bash
docker compose ps # service status + health
docker compose logs -f backend # follow one service
docker compose pull && docker compose up -d # update to latest release
docker compose down # stop (data persists in volumes)
docker compose down -v # stop and delete all data
```
## Verification
| Check | Endpoint |
|---|---|
| Frontend | `http://localhost/` |
| Backend liveness | `http://localhost/api/v1/health/liveness` |
| Backend readiness | `http://localhost/api/v1/health/readiness` |
| Auth health | `http://localhost/auth-health` |
| OpenAPI spec | `http://localhost/api/v1/openapi.yaml` |
## Local development
```bash
docker compose up -d postgres dragonfly mailpit # infrastructure only
cd apps/backend && go run ./cmd/server # API on :8080
npm run dev:auth # auth on :3001
npm run dev:frontend # dashboard (Vite)
```
Point the frontend at the stack by adding to `apps/frontend/.env.local`:
```env
VITE_API_BASE_URL=http://localhost/api/v1
VITE_AUTH_BASE_URL=http://localhost/auth
```
Or skip all of it: `http://localhost:<vite-port>/?demo=true` runs entirely in the browser.
## CLI
```bash
npm run build --workspace @primora/cli # → apps/cli/dist/cli.js
node apps/cli/dist/cli.js login # or: npx primora login once published
```
Supports session sign-in (`primora login`) and API-key mode (`primora login --api-key prm_…` or `PRIMORA_API_KEY`). Context lives in `~/.config/primora/config.json`; `primora use` picks org + project interactively. Commands: `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `audit list --follow`. Every command accepts `--json`.
## Desktop & phone
- **Desktop** — `apps/desktop` is a Tauri 2 shell (Linux/Windows/macOS). First
run asks for your deployment URL, then renders the same dashboard in a
webview with a system-tray presence. Nothing is bundled; your server does the
work. See [apps/desktop/README.md](apps/desktop/README.md).
- **Phone / PWA** — the dashboard ships a web manifest and service worker.
Open your deployment in a mobile browser and "Add to Home Screen"; the shell
is precached, API traffic always goes to the network. Chromium desktop
browsers get an in-app install banner.
## Quality gate
```bash
npm run check # backend tests + frontend typecheck + build + generated-code drift check
npm test # workspace tests
npm run generate:sqlc # regenerate sqlc after changing queries/schema
npm run generate:client # regenerate the TS client after changing openapi.yaml
```
## Repository layout
```
apps/
backend/ Go + Gin API, sqlc, migrations, object storage
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
frontend/ SolidJS + Tailwind dashboard
cli/ `primora` CLI — login, context, projects, buckets, objects, keys, audit
mcp/ `@primora/mcp` — MCP stdio server exposing `primora_*` tools
desktop/ Tauri 2 shell — connects to a deployment URL, tray presence
packages/
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
shared-types/
infra/nginx/ Reverse proxy config
scripts/ setup.sh, verify-production-ready.sh
```
## Documentation ## Documentation
- [QUICK_START.md](QUICK_START.md) — setup, configuration, troubleshooting - [QUICK_START.md](QUICK_START.md) - setup, configuration, troubleshooting
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) — original design specs - [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) - original design specs
- [CONTRIBUTING.md](CONTRIBUTING.md) — development workflow - `apps/backend/openapi/openapi.yaml` - API contract
- `apps/backend/openapi/openapi.yaml` — API contract
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
## Security
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
## License ## License
+2 -1
View File
@@ -5,7 +5,8 @@ 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, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js"; import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
import { authPool } from "./lib/db.js";
import { env } from "./lib/env.js"; import { env } from "./lib/env.js";
const redisClient = createClient({ const redisClient = createClient({
+79 -46
View File
@@ -1,50 +1,53 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { admin, jwt } from "better-auth/plugins"; import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration"; import { getMigrations } from "better-auth/db/migration";
import { Pool } from "pg";
import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js"; import { sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js";
import { env } from "./env.js"; import { env } from "./env.js";
export const authPool = new Pool({ // OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
connectionString: env.DATABASE_URL, // Self-hosted instances are email/password only — the env keys are ignored
}); // unless the managed flag is set.
const socialProviders = env.PRIMORA_MANAGED
const socialProviders = { ? {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? { ? {
github: { github: {
clientId: env.GITHUB_CLIENT_ID, clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET, clientSecret: env.GITHUB_CLIENT_SECRET,
}, },
} }
: {}), : {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? { ? {
google: { google: {
clientId: env.GOOGLE_CLIENT_ID, clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET, clientSecret: env.GOOGLE_CLIENT_SECRET,
}, },
} }
: {}), : {}),
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET ...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? { ? {
discord: { discord: {
clientId: env.DISCORD_CLIENT_ID, clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET, clientSecret: env.DISCORD_CLIENT_SECRET,
}, },
} }
: {}), : {}),
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET ...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
? { ? {
microsoft: { microsoft: {
clientId: env.MICROSOFT_CLIENT_ID, clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET, clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID, tenantId: env.MICROSOFT_TENANT_ID,
}, },
} }
: {}), : {}),
}; }
: {};
export const auth = betterAuth({ export const auth = betterAuth({
appName: "Primora", appName: "Primora",
@@ -63,16 +66,46 @@ export const auth = betterAuth({
}); });
}, },
}, },
databaseHooks: {
user: {
create: {
// 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 enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
if (!enabled) {
throw new APIError("FORBIDDEN", {
message: "Sign-up is disabled on this instance.",
});
}
},
},
},
},
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) { async sendVerificationEmail({ user, url }) {
await sendTransactionalEmail({ // Verification is optional — a misconfigured transport must not fail
to: user.email, // sign-up or sign-in.
subject: "Verify your Primora email", try {
text: `Verify your Primora email: ${url}`, await sendTransactionalEmail({
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`, to: user.email,
}); subject: "Verify your Primora email",
text: `Verify your Primora email: ${url}`,
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
});
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "verification_email_failed", error }));
}
}, },
}, },
// Enabled unconditionally — better-auth only turns this on when NODE_ENV is // Enabled unconditionally — better-auth only turns this on when NODE_ENV is
+9
View File
@@ -0,0 +1,9 @@
import { Pool } from "pg";
import { env } from "./env.js";
// Shared by better-auth and the instance-settings reader. Lives in its own
// module so settings.ts can be imported by auth.ts without a cycle.
export const authPool = new Pool({
connectionString: env.DATABASE_URL,
});
+12
View File
@@ -18,6 +18,15 @@ const envSchema = z.object({
SMTP_PORT: z.string().default("1025"), SMTP_PORT: z.string().default("1025"),
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(), SMTP_PASSWORD: z.string().optional(),
// Managed tier flag — OAuth providers only activate when this is "true".
// Self-hosted instances are email/password only.
PRIMORA_MANAGED: z.string().optional(),
// Bootstrap override for public sign-up. Without it (or an in-app setting),
// sign-up is closed once the first user exists.
SIGNUP_ENABLED: z.string().optional(),
// Decrypts secret instance settings written by the backend (mail keys etc.).
PRIMORA_ENCRYPTION_KEY: z.string().optional(),
SMTP_SECURE: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(), GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(), GITHUB_CLIENT_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(),
@@ -44,5 +53,8 @@ export const env = {
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS), JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
SMTP_PORT: Number(parsed.data.SMTP_PORT), SMTP_PORT: Number(parsed.data.SMTP_PORT),
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL, AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
PRIMORA_MANAGED: parsed.data.PRIMORA_MANAGED === "true",
SIGNUP_ENABLED: parsed.data.SIGNUP_ENABLED === "true",
SMTP_SECURE: parsed.data.SMTP_SECURE === "true",
}; };
+40 -23
View File
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
import { Resend } from "resend"; import { Resend } from "resend";
import { env } from "./env.js"; import { env } from "./env.js";
import { getStringSetting } from "./settings.js";
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null; // Mail config resolves per send: in-app setting → env var. This keeps
// SMTP/Resend editable from the dashboard without restarting the service.
const transporter = async function resolveMailConfig() {
!resend && env.SMTP_HOST const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
? nodemailer.createTransport({ const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
host: env.SMTP_HOST, if (resendApiKey) {
port: env.SMTP_PORT, return { from, resendApiKey } as const;
secure: false, }
auth: env.SMTP_USER const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
? { const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
user: env.SMTP_USER, const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
pass: env.SMTP_PASSWORD, const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
} const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
: undefined, return {
}) from,
: null; smtp: host
? {
host,
port: Number(portRaw) || 1025,
secure: secureRaw === "true",
auth: user ? { user, pass } : undefined,
}
: undefined,
} as const;
}
export async function sendTransactionalEmail(input: { export async function sendTransactionalEmail(input: {
to: string; to: string;
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
text: string; text: string;
html: string; html: string;
}) { }) {
if (resend) { const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) {
const resend = new Resend(cfg.resendApiKey);
await resend.emails.send({ await resend.emails.send({
from: env.MAIL_FROM, from: cfg.from,
to: [input.to], to: [input.to],
subject: input.subject, subject: input.subject,
text: input.text, text: input.text,
@@ -37,16 +50,20 @@ export async function sendTransactionalEmail(input: {
return; return;
} }
if (!transporter) { if (!("smtp" in cfg) || !cfg.smtp) {
throw new Error("No mail transport configured for auth service."); // Self-hosted instances may run without mail — auth still works, the
// email simply never goes out.
console.warn(
JSON.stringify({ level: "warn", msg: "mail_skipped_no_transport", to: input.to, subject: input.subject }),
);
return;
} }
await transporter.sendMail({ await nodemailer.createTransport(cfg.smtp).sendMail({
from: env.MAIL_FROM, from: cfg.from,
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
text: input.text, text: input.text,
html: input.html, html: input.html,
}); });
} }
+66
View File
@@ -0,0 +1,66 @@
import crypto from "node:crypto";
import { authPool } from "./db.js";
import { env } from "./env.js";
// Instance settings live in core.settings, written by the backend. A row
// overrides the matching env var; no row means env (or the caller's default).
// Secret values are AES-256-GCM ciphertext (base64, nonce || ct || tag)
// encrypted by the backend with PRIMORA_ENCRYPTION_KEY.
const CACHE_TTL_MS = 15_000;
const cache = new Map<string, { value: unknown; at: number }>();
export async function getSetting(key: string): Promise<unknown> {
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
const { rows } = await authPool.query(
`select value, secret from core.settings where key = $1`,
[key],
);
let value: unknown;
if (rows[0]) {
const raw = rows[0].value as unknown; // JSONB arrives parsed — a string
value = rows[0].secret ? decryptSetting(String(raw)) : raw;
}
cache.set(key, { value, at: Date.now() });
return value;
}
export async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
const v = await getSetting(key);
if (v === undefined) return fallback;
return v === true || v === "true";
}
export async function getStringSetting(key: string, fallback: string | undefined): Promise<string | undefined> {
const v = await getSetting(key);
if (v === undefined || v === "") return fallback;
return String(v);
}
function decryptSetting(ciphertextB64: string): string {
const key = decodeEncryptionKey(env.PRIMORA_ENCRYPTION_KEY);
const buf = Buffer.from(ciphertextB64, "base64");
const nonce = buf.subarray(0, 12);
const body = buf.subarray(12);
const tag = body.subarray(body.length - 16);
const ciphertext = body.subarray(0, body.length - 16);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}
// Same key formats as the Go Encryptor: 32 bytes as hex or base64.
function decodeEncryptionKey(raw: string | undefined): Buffer {
if (!raw) throw new Error("PRIMORA_ENCRYPTION_KEY is required to read secret settings");
const trimmed = raw.trim();
for (const decoded of [
Buffer.from(trimmed, "hex"),
Buffer.from(trimmed, "base64"),
Buffer.from(trimmed, "base64url"),
]) {
if (decoded.length === 32) return decoded;
}
throw new Error("PRIMORA_ENCRYPTION_KEY must decode to 32 bytes (hex or base64)");
}
@@ -0,0 +1,14 @@
-- +goose Up
-- Instance-level settings. In-app values override env vars; secrets are
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
CREATE TABLE core.settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
secret BOOLEAN NOT NULL DEFAULT FALSE,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- +goose Down
DROP TABLE IF EXISTS core.settings;
+18
View File
@@ -0,0 +1,18 @@
-- name: GetSetting :one
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1;
-- name: ListSettings :many
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key;
-- name: UpsertSetting :one
INSERT INTO core.settings (key, value, secret, updated_by)
VALUES ($1, $2::jsonb, $3, $4)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
secret = EXCLUDED.secret,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING *;
-- name: DeleteSetting :exec
DELETE FROM core.settings WHERE key = $1;
+3
View File
@@ -92,6 +92,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
return nil, fmt.Errorf("encryption key: %w", err) return nil, fmt.Errorf("encryption key: %w", err)
} }
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger) platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" { if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -126,10 +127,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin, APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
UserPerMinute: cfg.UserRateLimitPerMin, UserPerMinute: cfg.UserRateLimitPerMin,
}, },
RateLimitResolver: settings.RateLimitPerMinute,
}.ResolveActor()) }.ResolveActor())
handler := &handlers.HTTPHandler{ handler := &handlers.HTTPHandler{
Platform: platform, Platform: platform,
Settings: settings,
Validate: validator.New(), Validate: validator.New(),
Metrics: metrics, Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any { Readiness: func(c *gin.Context) map[string]any {
@@ -336,6 +336,14 @@ type CoreScheduledJobRun struct {
FinishedAt pgtype.Timestamptz `json:"finished_at"` FinishedAt pgtype.Timestamptz `json:"finished_at"`
} }
type CoreSetting struct {
Key string `json:"key"`
Value []byte `json:"value"`
Secret bool `json:"secret"`
UpdatedBy *string `json:"updated_by"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreUser struct { type CoreUser struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"` AuthSubject string `json:"auth_subject"`
@@ -46,6 +46,7 @@ type Querier interface {
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error) EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error) EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
@@ -68,6 +69,7 @@ type Querier interface {
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error) GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
@@ -97,6 +99,7 @@ type Querier interface {
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error) ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error) ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error) ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
@@ -132,6 +135,7 @@ type Querier interface {
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
} }
@@ -0,0 +1,116 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: settings.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteSetting = `-- name: DeleteSetting :exec
DELETE FROM core.settings WHERE key = $1
`
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
_, err := q.db.Exec(ctx, deleteSetting, key)
return err
}
const getSetting = `-- name: GetSetting :one
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1
`
type GetSettingRow struct {
Key string `json:"key"`
Value []byte `json:"value"`
Secret bool `json:"secret"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetSetting(ctx context.Context, key string) (GetSettingRow, error) {
row := q.db.QueryRow(ctx, getSetting, key)
var i GetSettingRow
err := row.Scan(
&i.Key,
&i.Value,
&i.Secret,
&i.UpdatedAt,
)
return i, err
}
const listSettings = `-- name: ListSettings :many
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key
`
type ListSettingsRow struct {
Key string `json:"key"`
Value []byte `json:"value"`
Secret bool `json:"secret"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListSettings(ctx context.Context) ([]ListSettingsRow, error) {
rows, err := q.db.Query(ctx, listSettings)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSettingsRow
for rows.Next() {
var i ListSettingsRow
if err := rows.Scan(
&i.Key,
&i.Value,
&i.Secret,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertSetting = `-- name: UpsertSetting :one
INSERT INTO core.settings (key, value, secret, updated_by)
VALUES ($1, $2::jsonb, $3, $4)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
secret = EXCLUDED.secret,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING key, value, secret, updated_by, updated_at
`
type UpsertSettingParams struct {
Key string `json:"key"`
Column2 []byte `json:"column_2"`
Secret bool `json:"secret"`
UpdatedBy *string `json:"updated_by"`
}
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) {
row := q.db.QueryRow(ctx, upsertSetting,
arg.Key,
arg.Column2,
arg.Secret,
arg.UpdatedBy,
)
var i CoreSetting
err := row.Scan(
&i.Key,
&i.Value,
&i.Secret,
&i.UpdatedBy,
&i.UpdatedAt,
)
return i, err
}
+5
View File
@@ -20,6 +20,7 @@ import (
type HTTPHandler struct { type HTTPHandler struct {
Platform *services.PlatformService Platform *services.PlatformService
Settings *services.SettingsService
Validate *validator.Validate Validate *validator.Validate
Metrics *observability.Metrics Metrics *observability.Metrics
Readiness func(*gin.Context) map[string]any Readiness func(*gin.Context) map[string]any
@@ -33,6 +34,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1") api := router.Group("/api/v1")
api.GET("/me", h.me) api.GET("/me", h.me)
api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
api.POST("/bootstrap", h.bootstrap) api.POST("/bootstrap", h.bootstrap)
api.GET("/organizations", h.organizations) api.GET("/organizations", h.organizations)
api.POST("/organizations", h.createOrganization) api.POST("/organizations", h.createOrganization)
+132
View File
@@ -0,0 +1,132 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// instancePublic serves unauthenticated bootstrap state to the login page.
// It intentionally exposes only what the sign-in UI needs.
func (h *HTTPHandler) instancePublic(c *gin.Context) {
users, err := h.Settings.AuthUserCount(c.Request.Context())
if err != nil {
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
return
}
signupEnabled := false
if users > 0 {
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
if err != nil {
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
return
}
}
c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled,
"bootstrap_required": users == 0,
"social_providers": managedSocialProviders(),
})
}
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
func managedSocialProviders() []string {
if os.Getenv("PRIMORA_MANAGED") != "true" {
return []string{}
}
providers := []string{}
for _, p := range []struct{ name, envKey string }{
{"github", "GITHUB_CLIENT_ID"},
{"google", "GOOGLE_CLIENT_ID"},
{"discord", "DISCORD_CLIENT_ID"},
{"microsoft", "MICROSOFT_CLIENT_ID"},
} {
if os.Getenv(p.envKey) != "" {
providers = append(providers, p.name)
}
}
return providers
}
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
// `user.role`), which is distinct from org/project roles.
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
actor, ok := middleware.RequireActor(c)
if !ok {
return false
}
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
return false
}
return true
}
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
if !h.requireInstanceAdmin(c) {
return
}
settings, err := h.Settings.ListResolved(c.Request.Context())
if err != nil {
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"settings": settings})
}
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
if !h.requireInstanceAdmin(c) {
return
}
key := c.Param("key")
if _, ok := services.SettingSpecs[key]; !ok {
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
return
}
var body struct {
Value json.RawMessage `json:"value" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
return
}
actor, _ := middleware.ActorFromContext(c)
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
return
}
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
if err != nil {
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
return
}
spec := services.SettingSpecs[key]
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
if !spec.Secret {
resp["value"] = value
}
c.JSON(http.StatusOK, resp)
}
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
if !h.requireInstanceAdmin(c) {
return
}
key := c.Param("key")
if _, ok := services.SettingSpecs[key]; !ok {
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
return
}
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
return
}
c.Status(http.StatusNoContent)
}
+15 -2
View File
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
Redis *redis.Client Redis *redis.Client
Verifier *auth.Verifier Verifier *auth.Verifier
RateLimits RateLimitConfig RateLimits RateLimitConfig
// RateLimitResolver overrides RateLimits with live instance settings when
// set — it resolves "user" and "api_key" limits per request (cached).
RateLimitResolver func(ctx context.Context, scope string) int
} }
type RateLimitConfig struct { type RateLimitConfig struct {
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
UserPerMinute int UserPerMinute int
} }
func (m AuthMiddleware) rateLimit(ctx context.Context, scope string, fallback int) int {
if m.RateLimitResolver == nil {
return fallback
}
if v := m.RateLimitResolver(ctx, scope); v >= 0 {
return v
}
return fallback
}
func RequestID() gin.HandlerFunc { func RequestID() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
requestID := c.Request.Header.Get("X-Request-ID") requestID := c.Request.Header.Get("X-Request-ID")
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
if actor.UserID != nil { if actor.UserID != nil {
userIdentity = actor.UserID.String() userIdentity = actor.UserID.String()
} }
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") { if !m.enforceRateLimit(c, "user", userIdentity, m.rateLimit(c.Request.Context(), "user", m.RateLimits.UserPerMinute), "User rate limit exceeded") {
return return
} }
c.Set(actorKey, actor) c.Set(actorKey, actor)
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error()) apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
return return
} }
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") { if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.rateLimit(c.Request.Context(), "api_key", m.RateLimits.APIKeyPerMinute), "API key rate limit exceeded") {
return return
} }
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) { if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
@@ -56,3 +56,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error { func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
return r.queries.TouchAPIKey(ctx, id) return r.queries.TouchAPIKey(ctx, id)
} }
// Auth-user lookups hit the better-auth tables in the public schema. They are
// raw queries on purpose: sqlc only knows the core schema, and a view would
// break when the backend migrates before the auth service has created its
// tables.
func (r *CoreRepository) GetAuthUserRole(ctx context.Context, authSubject string) (string, error) {
var role string
err := r.pool.QueryRow(ctx, `select coalesce(role, 'user') from public."user" where id = $1`, authSubject).Scan(&role)
return role, err
}
func (r *CoreRepository) CountAuthUsers(ctx context.Context) (int64, error) {
var n int64
err := r.pool.QueryRow(ctx, `select count(*) from public."user"`).Scan(&n)
return n, err
}
func (r *CoreRepository) GetSetting(ctx context.Context, key string) (db.GetSettingRow, error) {
return r.queries.GetSetting(ctx, key)
}
func (r *CoreRepository) ListSettings(ctx context.Context) ([]db.ListSettingsRow, error) {
return r.queries.ListSettings(ctx)
}
func (r *CoreRepository) UpsertSetting(ctx context.Context, params db.UpsertSettingParams) (db.CoreSetting, error) {
return r.queries.UpsertSetting(ctx, params)
}
func (r *CoreRepository) DeleteSetting(ctx context.Context, key string) error {
return r.queries.DeleteSetting(ctx, key)
}
+345
View File
@@ -0,0 +1,345 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strconv"
"sync"
"time"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// SettingSpec describes a settable instance key: value type, the env var used
// as fallback when no DB row exists, and a built-in default.
type SettingSpec struct {
Key string
Type string // "bool" | "int" | "string"
Secret bool
EnvKey string
Default string
}
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
// defaults — an app value wins when present.
var SettingSpecs = map[string]SettingSpec{
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
}
const settingsCacheTTL = 15 * time.Second
// resolvedSetting holds the stored string form: plaintext for normal keys,
// base64 ciphertext for secrets — Resolve() decrypts on read.
type resolvedSetting struct {
value string
secret bool
}
// SettingsService resolves instance settings: DB row > env var > default.
// Values cache briefly so per-request consumers (rate limits) stay cheap.
type SettingsService struct {
repo *repositories.CoreRepository
encryptor *secrets.Encryptor
mu sync.Mutex
cache map[string]resolvedSetting
cacheAt time.Time
}
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
return &SettingsService{repo: repo, encryptor: encryptor}
}
// Resolve returns the effective string value plus its source
// ("app" | "env" | "default"). Secret DB values are decrypted.
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
spec, ok := SettingSpecs[key]
if !ok {
return "", "", fmt.Errorf("unknown setting: %s", key)
}
row, err := s.stored(ctx, key)
if err != nil {
return "", "", err
}
if row != nil {
raw := row.value
if row.secret {
raw, err = s.decrypt(raw)
if err != nil {
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
}
}
return raw, "app", nil
}
if env := os.Getenv(spec.EnvKey); env != "" {
return env, "env", nil
}
return spec.Default, "default", nil
}
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return false, err
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
}
return v, nil
}
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return 0, err
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
}
return v, nil
}
// RateLimitPerMinute is the hot-path helper for the auth middleware.
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
key := "ratelimit.user_per_minute"
if scope == "api_key" {
key = "ratelimit.api_key_per_minute"
}
v, err := s.GetInt(ctx, key)
if err != nil {
spec := SettingSpecs[key]
fallback, _ := strconv.Atoi(spec.Default)
return fallback
}
return v
}
// ValidateAndStore type-checks the raw JSON value against the registry and
// persists it. Secret values are encrypted before they touch the DB.
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
spec, ok := SettingSpecs[key]
if !ok {
return fmt.Errorf("unknown setting: %s", key)
}
plain, err := normalizeSetting(spec, raw)
if err != nil {
return err
}
value := []byte(strconv.Quote(plain))
if spec.Secret {
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
}
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
Key: key,
Column2: value,
Secret: spec.Secret,
UpdatedBy: &updatedBy,
})
if err != nil {
return err
}
s.invalidate()
return nil
}
func (s *SettingsService) Delete(ctx context.Context, key string) error {
if _, ok := SettingSpecs[key]; !ok {
return fmt.Errorf("unknown setting: %s", key)
}
if err := s.repo.DeleteSetting(ctx, key); err != nil {
return err
}
s.invalidate()
return nil
}
// IsAdmin reports whether the actor holds the platform admin role
// (better-auth `user.role`), not an org/project role.
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
return err == nil && role == "admin"
}
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
return s.repo.CountAuthUsers(ctx)
}
// ListResolved returns every registered key with its effective state.
// Secret values are masked — never returned.
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
stored, err := s.loadStored(ctx)
if err != nil {
return nil, err
}
out := make([]ResolvedSetting, 0, len(SettingSpecs))
for _, key := range sortedSettingKeys() {
spec := SettingSpecs[key]
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
if row, ok := stored[key]; ok {
entry.IsSet = true
entry.Source = "app"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(row.value))
}
} else if env := os.Getenv(spec.EnvKey); env != "" {
entry.Source = "env"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(env))
}
} else {
entry.Source = "default"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
}
}
out = append(out, entry)
}
return out, nil
}
type ResolvedSetting struct {
Key string `json:"key"`
Type string `json:"type"`
Secret bool `json:"secret"`
IsSet bool `json:"is_set"`
Source string `json:"source"`
Value json.RawMessage `json:"value,omitempty"`
}
func sortedSettingKeys() []string {
keys := make([]string, 0, len(SettingSpecs))
for k := range SettingSpecs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
return s.cache, nil
}
rows, err := s.repo.ListSettings(ctx)
if err != nil {
return nil, err
}
fresh := make(map[string]resolvedSetting, len(rows))
for _, r := range rows {
encoded, err := unwrapSettingValue(r.Value)
if err != nil {
continue // unreadable row — treat as unset rather than break consumers
}
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
}
s.cache = fresh
s.cacheAt = time.Now()
return fresh, nil
}
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
all, err := s.loadStored(ctx)
if err != nil {
// cold-cache fallback: direct lookup
row, err := s.repo.GetSetting(ctx, key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
encoded, err := unwrapSettingValue(row.Value)
if err != nil {
return nil, err
}
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
}
if row, ok := all[key]; ok {
return &row, nil
}
return nil, nil
}
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
plain, err := s.encryptor.Decrypt(raw)
if err != nil {
return "", err
}
return string(plain), nil
}
func (s *SettingsService) invalidate() {
s.mu.Lock()
defer s.mu.Unlock()
s.cache = nil
}
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
// string — the plaintext for normal keys, base64 ciphertext for secrets.
func unwrapSettingValue(raw []byte) (string, error) {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return "", err
}
return encoded, nil
}
// normalizeSetting validates raw JSON against the spec type and returns the
// canonical string form stored/compared internally.
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
switch spec.Type {
case "bool":
var v bool
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a boolean", spec.Key)
}
return strconv.FormatBool(v), nil
case "int":
var v int
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects an integer", spec.Key)
}
if v < 0 {
return "", fmt.Errorf("%s must be >= 0", spec.Key)
}
return strconv.Itoa(v), nil
default:
var v string
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a string", spec.Key)
}
if len(v) > 512 {
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
}
return v, nil
}
}
@@ -0,0 +1,68 @@
package services
import (
"encoding/json"
"testing"
)
func TestNormalizeSetting(t *testing.T) {
t.Parallel()
boolSpec := SettingSpecs["auth.signup_enabled"]
intSpec := SettingSpecs["ratelimit.user_per_minute"]
strSpec := SettingSpecs["mail.smtp_host"]
cases := []struct {
name string
spec SettingSpec
raw string
want string
wantErr bool
}{
{name: "bool true", spec: boolSpec, raw: `true`, want: "true"},
{name: "bool false", spec: boolSpec, raw: `false`, want: "false"},
{name: "bool rejects string", spec: boolSpec, raw: `"true"`, wantErr: true},
{name: "bool rejects int", spec: boolSpec, raw: `1`, wantErr: true},
{name: "int accepts", spec: intSpec, raw: `120`, want: "120"},
{name: "int zero allowed", spec: intSpec, raw: `0`, want: "0"},
{name: "int rejects negative", spec: intSpec, raw: `-5`, wantErr: true},
{name: "int rejects float", spec: intSpec, raw: `1.5`, wantErr: true},
{name: "int rejects string", spec: intSpec, raw: `"120"`, wantErr: true},
{name: "string accepts", spec: strSpec, raw: `"smtp.example.com"`, want: "smtp.example.com"},
{name: "string rejects number", spec: strSpec, raw: `42`, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeSetting(tc.spec, json.RawMessage(tc.raw))
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestSortedSettingKeysDeterministic(t *testing.T) {
t.Parallel()
first := sortedSettingKeys()
second := sortedSettingKeys()
if len(first) != len(SettingSpecs) {
t.Fatalf("expected %d keys, got %d", len(SettingSpecs), len(first))
}
for i := range first {
if first[i] != second[i] {
t.Fatalf("key order not deterministic at %d: %q vs %q", i, first[i], second[i])
}
}
}
+130
View File
@@ -17,6 +17,7 @@ tags:
- name: Integrations - name: Integrations
- name: Webhooks - name: Webhooks
- name: Automation - name: Automation
- name: Instance
paths: paths:
/health/liveness: /health/liveness:
get: get:
@@ -67,6 +68,87 @@ paths:
$ref: "#/components/schemas/MeResponse" $ref: "#/components/schemas/MeResponse"
"401": "401":
$ref: "#/components/responses/ErrorResponse" $ref: "#/components/responses/ErrorResponse"
/instance/public:
get:
tags: [Instance]
operationId: getInstancePublic
summary: Public instance state for the login page — no auth required
responses:
"200":
description: Public instance configuration
content:
application/json:
schema:
$ref: "#/components/schemas/InstancePublicConfig"
/instance/settings:
get:
tags: [Instance]
operationId: listInstanceSettings
summary: List instance settings (platform admin only)
security:
- bearerAuth: []
responses:
"200":
description: Resolved instance settings
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceSettingsResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"403":
$ref: "#/components/responses/ErrorResponse"
/instance/settings/{key}:
put:
tags: [Instance]
operationId: updateInstanceSetting
summary: Set an instance setting override (platform admin only)
security:
- bearerAuth: []
parameters:
- name: key
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateInstanceSettingRequest"
responses:
"200":
description: Setting stored
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceSetting"
"400":
$ref: "#/components/responses/ErrorResponse"
"403":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Instance]
operationId: deleteInstanceSetting
summary: Revert a setting to its env/default value (platform admin only)
security:
- bearerAuth: []
parameters:
- name: key
in: path
required: true
schema:
type: string
responses:
"204":
description: Setting reverted
"403":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/bootstrap: /bootstrap:
post: post:
tags: [Platform] tags: [Platform]
@@ -1952,6 +2034,54 @@ components:
type: string type: string
required: [code, message] required: [code, message]
required: [status, error] required: [status, error]
InstancePublicConfig:
type: object
properties:
signup_enabled:
type: boolean
description: Whether public sign-up is open (bootstrap always allowed)
bootstrap_required:
type: boolean
description: True when no user exists yet — first sign-up becomes instance admin
social_providers:
type: array
items:
type: string
description: Enabled OAuth providers — empty on self-hosted
required: [signup_enabled, bootstrap_required, social_providers]
InstanceSetting:
type: object
properties:
key:
type: string
type:
type: string
enum: [bool, int, string]
secret:
type: boolean
is_set:
type: boolean
description: True when an in-app override exists
source:
type: string
enum: [app, env, default]
value:
description: Effective value — never returned for secret keys
required: [key, type, secret, is_set, source]
InstanceSettingsResponse:
type: object
properties:
settings:
type: array
items:
$ref: "#/components/schemas/InstanceSetting"
required: [settings]
UpdateInstanceSettingRequest:
type: object
properties:
value:
description: JSON value matching the key's declared type
required: [value]
BootstrapRequest: BootstrapRequest:
type: object type: object
properties: properties:
+23
View File
@@ -0,0 +1,23 @@
# @primora/cli
`primora` - command-line client for a Primora deployment.
## Build
```bash
npm run build --workspace @primora/cli # → dist/cli.js
node dist/cli.js login # or: npx primora login once published
```
## Auth
- `primora login` - session sign-in against your deployment.
- `primora login --api-key prm_…` - or export `PRIMORA_API_KEY`.
Context lives in `~/.config/primora/config.json`; `primora use` picks org +
project interactively.
## Commands
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
`audit list --follow`. Every command accepts `--json`.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.6.0", "version": "0.7.1",
"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.6.0", "version": "0.7.1",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+81 -1
View File
@@ -4,6 +4,7 @@ import {
CreateInvitationRequest, CreateInvitationRequest,
CreateOrganizationRequest, CreateOrganizationRequest,
HealthService, HealthService,
InstanceService,
OrganizationsService, OrganizationsService,
PlatformService, PlatformService,
ProjectsService, ProjectsService,
@@ -18,6 +19,7 @@ import {
CopyBucketObjectRequest, CopyBucketObjectRequest,
type Bucket, type Bucket,
type BucketObject, type BucketObject,
type InstanceSetting,
type OrganizationSummary, type OrganizationSummary,
type ProjectSummary, type ProjectSummary,
} from "@primora/api-client"; } from "@primora/api-client";
@@ -141,6 +143,7 @@ export default function App() {
const [memberMessage, setMemberMessage] = createSignal(""); const [memberMessage, setMemberMessage] = createSignal("");
const [apiKeySecret, setApiKeySecret] = createSignal(""); const [apiKeySecret, setApiKeySecret] = createSignal("");
const [collectionsMessage, setCollectionsMessage] = createSignal(""); const [collectionsMessage, setCollectionsMessage] = createSignal("");
const [instanceMessage, setInstanceMessage] = createSignal("");
const [selectedOrganizationID, setSelectedOrganizationID] = createSignal<string | undefined>(); const [selectedOrganizationID, setSelectedOrganizationID] = createSignal<string | undefined>();
const [selectedProjectID, setSelectedProjectID] = createSignal<string | undefined>(); const [selectedProjectID, setSelectedProjectID] = createSignal<string | undefined>();
@@ -237,6 +240,47 @@ export default function App() {
} }
}); });
// Public instance state — fetched before login so the auth page can hide
// sign-up/social options the instance does not offer.
const [instancePublic] = createResource(async () => {
if (isDemo) return { signup_enabled: true, bootstrap_required: false, social_providers: [] };
try {
return await InstanceService.getInstancePublic();
} catch {
// Endpoint unreachable (backend down or pre-migration): keep sign-in
// visible rather than lock the page out.
return undefined;
}
});
const canSignUp = createMemo(() => {
const cfg = instancePublic();
if (!cfg) return true; // unknown — let the backend enforce
return cfg.bootstrap_required || cfg.signup_enabled;
});
const enabledSocialProviders = createMemo(() => instancePublic()?.social_providers ?? []);
const isPlatformAdmin = createMemo(() => {
const role = (session()?.data?.user as { role?: string } | undefined)?.role;
return isDemo || role === "admin";
});
const [instanceSettings, { refetch: refetchInstanceSettings }] = createResource(
() => (activeView() === "settings" && isPlatformAdmin() && session()?.data ? true : null),
async (): Promise<InstanceSetting[]> => {
if (isDemo) return demoService.listInstanceSettings();
return (await InstanceService.listInstanceSettings()).settings;
},
);
// Clamp the auth mode to what the instance permits.
createEffect(() => {
const cfg = instancePublic();
if (!cfg || isDemo) return;
if (cfg.bootstrap_required) setMode("sign-up");
else if (!cfg.signup_enabled) setMode("sign-in");
});
const [platform, { refetch: refetchPlatform }] = createResource( const [platform, { refetch: refetchPlatform }] = createResource(
() => session()?.data?.user.id ?? null, () => session()?.data?.user.id ?? null,
async () => { async () => {
@@ -312,6 +356,7 @@ export default function App() {
const storagePending = createMemo(() => isPendingPrefix("storage-")); const storagePending = createMemo(() => isPendingPrefix("storage-"));
const collectionPending = createMemo(() => isPendingPrefix("collection-")); const collectionPending = createMemo(() => isPendingPrefix("collection-"));
const documentPending = createMemo(() => isPendingPrefix("document-")); const documentPending = createMemo(() => isPendingPrefix("document-"));
const instancePending = createMemo(() => isPendingPrefix("instance-"));
const [apiKeys, { refetch: refetchAPIKeys }] = createResource( const [apiKeys, { refetch: refetchAPIKeys }] = createResource(
() => activeProject()?.id ?? null, () => activeProject()?.id ?? null,
@@ -691,7 +736,7 @@ export default function App() {
try { try {
if (mode() === "sign-up") { if (mode() === "sign-up") {
await authClient.signUp.email({ email: email(), password: password(), name: name() }); await authClient.signUp.email({ email: email(), password: password(), name: name() });
setAuthMessage("Account created. Check Mailpit (or your SMTP provider) for the verification email."); setAuthMessage("Account created — signing you in.");
} else { } else {
await authClient.signIn.email({ email: email(), password: password() }); await authClient.signIn.email({ email: email(), password: password() });
} }
@@ -1431,6 +1476,32 @@ export default function App() {
}); });
} }
async function saveInstanceSetting(key: string, value: unknown) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.updateInstanceSetting({ key, requestBody: { value } });
await refetchInstanceSettings();
setInstanceMessage("Setting saved.");
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to save setting"));
}
});
}
async function resetInstanceSetting(key: string) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.deleteInstanceSetting({ key });
await refetchInstanceSettings();
setInstanceMessage(`"${key}" reverted to its env/default value.`);
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to reset setting"));
}
});
}
async function startSocial(provider: "github" | "google" | "discord" | "microsoft") { async function startSocial(provider: "github" | "google" | "discord" | "microsoft") {
if (isDemo) { if (isDemo) {
setAuthMessage("Demo mode is active — social auth is simulated."); setAuthMessage("Demo mode is active — social auth is simulated.");
@@ -1500,6 +1571,9 @@ export default function App() {
name={name()} name={name()}
authMessage={authMessage()} authMessage={authMessage()}
authPending={authPending()} authPending={authPending()}
canSignUp={canSignUp()}
bootstrapRequired={instancePublic()?.bootstrap_required ?? false}
enabledProviders={enabledSocialProviders()}
onModeChange={setMode} onModeChange={setMode}
onEmailChange={setEmail} onEmailChange={setEmail}
onPasswordChange={setPassword} onPasswordChange={setPassword}
@@ -1816,6 +1890,12 @@ export default function App() {
<Show when={activeView() === "settings"}> <Show when={activeView() === "settings"}>
<SettingsPage <SettingsPage
isPlatformAdmin={isPlatformAdmin()}
instanceSettings={instanceSettings()}
instancePending={instancePending() || instanceSettings.loading}
instanceMessage={instanceMessage()}
onSaveInstanceSetting={(key, value) => void saveInstanceSetting(key, value)}
onResetInstanceSetting={(key) => void resetInstanceSetting(key)}
apiKeys={apiKeys()} apiKeys={apiKeys()}
apiKeyName={apiKeyName()} apiKeyName={apiKeyName()}
apiKeyScopes={apiKeyScopes()} apiKeyScopes={apiKeyScopes()}
+3 -2
View File
@@ -13,16 +13,17 @@ const sizeClasses = {
}; };
export function Input(props: InputProps) { export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "size", "class"]); const [local, rest] = splitProps(props, ["label", "error", "size", "class", "id"]);
const size = () => local.size ?? "md"; const size = () => local.size ?? "md";
return ( return (
<div class="w-full"> <div class="w-full">
<Show when={local.label}> <Show when={local.label}>
<label class="label">{local.label}</label> <label class="label" for={local.id}>{local.label}</label>
</Show> </Show>
<input <input
id={local.id}
class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`} class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest} {...rest}
/> />
+14
View File
@@ -23,6 +23,7 @@ import type {
Document, Document,
IntegrationAnalytics, IntegrationAnalytics,
IntegrationTestResult, IntegrationTestResult,
InstanceSetting,
MeResponse, MeResponse,
MetricPoint, MetricPoint,
OrganizationSummary, OrganizationSummary,
@@ -608,6 +609,19 @@ class DemoService {
})); }));
} }
async listAuthUsers() { await this.delay(); return authUsers; } async listAuthUsers() { await this.delay(); return authUsers; }
// Mirrors InstanceService.listInstanceSettings — representative registry
// slice so demo mode can show the Instance settings tab.
async listInstanceSettings(): Promise<InstanceSetting[]> {
await this.delay();
return [
{ key: "auth.signup_enabled", type: "bool", secret: false, is_set: false, source: "default", value: false },
{ key: "mail.from", type: "string", secret: false, is_set: false, source: "env", value: "Primora <[email protected]>" },
{ key: "mail.smtp_host", type: "string", secret: false, is_set: true, source: "app", value: "mailpit" },
{ key: "mail.smtp_password", type: "string", secret: true, is_set: false, source: "default" },
{ key: "ratelimit.user_per_minute", type: "int", secret: false, is_set: false, source: "default", value: 240 },
] as InstanceSetting[];
}
async listCollections() { await this.delay(); return collections; } async listCollections() { await this.delay(); return collections; }
async listDocuments() { await this.delay(); return documents; } async listDocuments() { await this.delay(); return documents; }
+80 -41
View File
@@ -18,6 +18,12 @@ interface LoginPageProps {
name: string; name: string;
authMessage: string; authMessage: string;
authPending: boolean; authPending: boolean;
/** False when the instance closed public sign-up (and bootstrap is done). */
canSignUp: boolean;
/** True while no user exists — the first sign-up becomes instance admin. */
bootstrapRequired: boolean;
/** Social providers the instance offers (empty on self-hosted). */
enabledProviders: string[];
onModeChange: (mode: AuthMode) => void; onModeChange: (mode: AuthMode) => void;
onEmailChange: (value: string) => void; onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void; onPasswordChange: (value: string) => void;
@@ -35,7 +41,9 @@ const socialProviders: { id: Provider; label: string; logo: (p: { class?: string
]; ];
export function LoginPage(props: LoginPageProps) { export function LoginPage(props: LoginPageProps) {
const isSignIn = () => props.mode === "sign-in"; const isSignIn = () => !props.bootstrapRequired && props.mode === "sign-in";
const providers = () =>
socialProviders.filter((p) => props.enabledProviders.includes(p.id));
return ( return (
<div class="auth-wrap"> <div class="auth-wrap">
@@ -70,41 +78,54 @@ export function LoginPage(props: LoginPageProps) {
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span> <span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
</div> </div>
<h1 style="font-size: 1.25rem; letter-spacing: -0.02em;"> <h1 style="font-size: 1.25rem; letter-spacing: -0.02em;">
{isSignIn() ? "Sign in" : "Create your account"} {isSignIn() ? "Sign in" : props.bootstrapRequired ? "Create the admin account" : "Create your account"}
</h1> </h1>
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)"> <p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
{isSignIn() {isSignIn()
? "Access your workspace" ? "Access your workspace"
: "One account for the whole workspace"} : props.bootstrapRequired
? "The first account becomes the instance administrator"
: "One account for the whole workspace"}
</p> </p>
</div> </div>
{/* mode toggle */} {/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */}
<div class="seg mb-6" role="tablist" aria-label="Authentication mode"> <Show when={!props.bootstrapRequired}>
<button <div class="seg mb-6" role="tablist" aria-label="Authentication mode">
type="button" <button
role="tab" type="button"
aria-selected={isSignIn()} role="tab"
class={`seg-item ${isSignIn() ? "active" : ""}`} aria-selected={isSignIn()}
onClick={() => props.onModeChange("sign-in")} class={`seg-item ${isSignIn() ? "active" : ""}`}
> onClick={() => props.onModeChange("sign-in")}
Sign in >
</button> Sign in
<button </button>
type="button" <Show when={props.canSignUp}>
role="tab" <button
aria-selected={!isSignIn()} type="button"
class={`seg-item ${!isSignIn() ? "active" : ""}`} role="tab"
onClick={() => props.onModeChange("sign-up")} aria-selected={!isSignIn()}
> class={`seg-item ${!isSignIn() ? "active" : ""}`}
Sign up onClick={() => props.onModeChange("sign-up")}
</button> >
</div> Sign up
</button>
</Show>
</div>
</Show>
<Show when={props.bootstrapRequired}>
<div class="message message-info mb-6">
This instance has no users yet. Create the first account — it becomes the administrator.
</div>
</Show>
<form onSubmit={props.onSubmit} class="space-y-4"> <form onSubmit={props.onSubmit} class="space-y-4">
<Show when={!isSignIn()}> <Show when={!isSignIn()}>
<Input <Input
label="Name" label="Name"
id="name"
name="name"
type="text" type="text"
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
value={props.name} value={props.name}
@@ -115,15 +136,19 @@ export function LoginPage(props: LoginPageProps) {
</Show> </Show>
<Input <Input
label="Email" label="Email"
id="email"
name="email"
type="email" type="email"
placeholder="[email protected]" placeholder="[email protected]"
value={props.email} value={props.email}
onInput={(e) => props.onEmailChange(e.currentTarget.value)} onInput={(e) => props.onEmailChange(e.currentTarget.value)}
autocomplete="email" autocomplete="username"
required required
/> />
<Input <Input
label="Password" label="Password"
id="password"
name="password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={props.password} value={props.password}
@@ -143,29 +168,43 @@ export function LoginPage(props: LoginPageProps) {
> >
<Show <Show
when={props.authPending} when={props.authPending}
fallback={isSignIn() ? "Sign in" : "Create account"} fallback={
isSignIn()
? "Sign in"
: props.bootstrapRequired
? "Create admin account"
: "Create account"
}
> >
<span class="spinner" /> Working… <span class="spinner" /> Working…
</Show> </Show>
</button> </button>
</form> </form>
<div class="divider my-6">or continue with</div> <Show when={!props.canSignUp && !props.bootstrapRequired}>
<p class="mt-4 text-center" style="font-size: 0.8125rem; color: var(--text-3)">
Public sign-up is closed on this instance.
</p>
</Show>
<div class="grid grid-cols-4 gap-2"> <Show when={providers().length > 0}>
{socialProviders.map((p) => ( <div class="divider my-6">or continue with</div>
<button
type="button" <div class={`grid gap-2 ${providers().length === 1 ? "grid-cols-1" : providers().length === 2 ? "grid-cols-2" : "grid-cols-4"}`}>
class="btn btn-secondary btn-icon w-full" {providers().map((p) => (
onClick={() => props.onSocial(p.id)} <button
disabled={props.authPending} type="button"
title={`Continue with ${p.label}`} class="btn btn-secondary btn-icon w-full"
aria-label={`Continue with ${p.label}`} onClick={() => props.onSocial(p.id)}
> disabled={props.authPending}
<p.logo class="w-4 h-4" /> title={`Continue with ${p.label}`}
</button> aria-label={`Continue with ${p.label}`}
))} >
</div> <p.logo class="w-4 h-4" />
</button>
))}
</div>
</Show>
<Show when={props.onTryDemo}> <Show when={props.onTryDemo}>
<div class="divider my-6" /> <div class="divider my-6" />
+183 -2
View File
@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js"; import { For, Show, createSignal } from "solid-js";
import type { ApiKey } from "@primora/api-client"; import type { ApiKey, InstanceSetting } from "@primora/api-client";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal"; import { Modal, ModalFooter } from "../components/Modal";
import { Input } from "../components/Input"; import { Input } from "../components/Input";
@@ -25,6 +25,12 @@ const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
]; ];
interface SettingsPageProps { interface SettingsPageProps {
isPlatformAdmin: boolean;
instanceSettings?: InstanceSetting[];
instancePending: boolean;
instanceMessage: string;
onSaveInstanceSetting: (key: string, value: unknown) => void;
onResetInstanceSetting: (key: string) => void;
apiKeys?: ApiKey[]; apiKeys?: ApiKey[];
apiKeyName: string; apiKeyName: string;
apiKeyScopes: string[]; apiKeyScopes: string[];
@@ -50,8 +56,27 @@ interface SettingsPageProps {
formatDate: (value?: string | null) => string; formatDate: (value?: string | null) => string;
} }
const INSTANCE_GROUPS: { prefix: string; title: string; hint: string }[] = [
{ prefix: "auth.", title: "Authentication", hint: "Who can create accounts on this instance." },
{ prefix: "mail.", title: "Mail", hint: "Outgoing email — Resend or SMTP. In-app values override .env." },
{ prefix: "ratelimit.", title: "Rate limits", hint: "Requests per minute, enforced by the API." },
];
const SETTING_LABELS: Record<string, string> = {
"auth.signup_enabled": "Public sign-up",
"mail.from": "From address",
"mail.resend_api_key": "Resend API key",
"mail.smtp_host": "SMTP host",
"mail.smtp_port": "SMTP port",
"mail.smtp_user": "SMTP user",
"mail.smtp_password": "SMTP password",
"mail.smtp_secure": "SMTP TLS",
"ratelimit.user_per_minute": "User requests / minute",
"ratelimit.api_key_per_minute": "API key requests / minute",
};
export function SettingsPage(props: SettingsPageProps) { export function SettingsPage(props: SettingsPageProps) {
const [tab, setTab] = createSignal<"api-keys" | "organization">("api-keys"); const [tab, setTab] = createSignal<"api-keys" | "organization" | "instance">("api-keys");
const [copied, setCopied] = createSignal(false); const [copied, setCopied] = createSignal(false);
const [createOrgOpen, setCreateOrgOpen] = createSignal(false); const [createOrgOpen, setCreateOrgOpen] = createSignal(false);
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false); const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
@@ -94,6 +119,14 @@ export function SettingsPage(props: SettingsPageProps) {
> >
Organization Organization
</button> </button>
<Show when={props.isPlatformAdmin}>
<button
class={`tab ${tab() === "instance" ? "active" : ""}`}
onClick={() => setTab("instance")}
>
Instance
</button>
</Show>
</div> </div>
{/* ---------------- API keys ---------------- */} {/* ---------------- API keys ---------------- */}
@@ -322,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
</Show> </Show>
</Show> </Show>
{/* ---------------- Instance (platform admin) ---------------- */}
<Show when={tab() === "instance" && props.isPlatformAdmin}>
<Show when={props.instanceMessage}>
<div class="message message-neutral">{props.instanceMessage}</div>
</Show>
<p class="text-xs text-text-3 mb-4">
In-app values override the matching environment variables. Secrets are stored encrypted
and never shown back.
</p>
<For each={INSTANCE_GROUPS}>
{(group) => {
const rows = () => (props.instanceSettings ?? []).filter((s) => s.key.startsWith(group.prefix));
return (
<Show when={rows().length > 0}>
<div class="card card-flush mb-4">
<div class="card-header">
<span class="card-header-title">{group.title}</span>
<span class="card-header-description">{group.hint}</span>
</div>
<div class="divide-y" style="border-color:var(--border)">
<For each={rows()}>
{(setting) => (
<SettingRow
setting={setting}
pending={props.instancePending}
onSave={(value) => props.onSaveInstanceSetting(setting.key, value)}
onReset={() => props.onResetInstanceSetting(setting.key)}
/>
)}
</For>
</div>
</div>
</Show>
);
}}
</For>
<Show when={!props.instanceSettings}>
<div class="card empty-state">
<div class="empty-state-icon"><IconKey class="w-5 h-5" /></div>
<p class="empty-state-title">Loading instance settings</p>
</div>
</Show>
</Show>
{/* API key secret modal — shown while App holds a fresh secret */} {/* API key secret modal — shown while App holds a fresh secret */}
<Show when={props.apiKeySecret}> <Show when={props.apiKeySecret}>
<Modal <Modal
@@ -412,3 +492,104 @@ export function SettingsPage(props: SettingsPageProps) {
</div> </div>
); );
} }
/** Values arrive as JSON strings ("true", "240") — decode per declared type. */
function settingText(setting: InstanceSetting): string {
if (setting.value === undefined || setting.value === null) return "";
return String(setting.value);
}
const SOURCE_BADGE: Record<string, "success" | "primary" | "neutral"> = {
app: "success",
env: "primary",
default: "neutral",
};
function SettingRow(props: {
setting: InstanceSetting;
pending: boolean;
onSave: (value: unknown) => void;
onReset: () => void;
}) {
const s = () => props.setting;
const [draft, setDraft] = createSignal(settingText(props.setting));
const dirty = () => {
if (s().type === "bool") return false; // toggles save instantly
return draft() !== settingText(s());
};
const saveDraft = () => {
if (s().type === "int") {
const n = Number(draft());
if (!Number.isInteger(n) || n < 0) return;
props.onSave(n);
return;
}
props.onSave(draft());
};
return (
<div class="flex items-center gap-3 px-4 py-3 flex-wrap">
<div class="flex-1" style="min-width:12rem">
<div class="text-sm font-medium text-text-1">
{SETTING_LABELS[s().key] ?? s().key}
</div>
<code class="text-xs text-text-3">{s().key}</code>
</div>
<Badge variant={SOURCE_BADGE[s().source] ?? "neutral"}>
{s().is_set ? "in-app" : s().source}
</Badge>
<Show
when={s().type === "bool"}
fallback={
<div class="flex items-center gap-2">
<input
class="input input-sm"
style="width:12rem"
type={s().secret ? "password" : s().type === "int" ? "number" : "text"}
min={s().type === "int" ? 0 : undefined}
placeholder={s().secret ? (s().is_set ? "•••••••• (stored)" : "not set") : ""}
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
disabled={props.pending}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
<button
class="btn btn-secondary btn-sm"
onClick={saveDraft}
disabled={props.pending || !dirty()}
>
Save
</button>
</div>
}
>
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
class="accent-[var(--accent)]"
checked={settingText(s()) === "true"}
disabled={props.pending}
onChange={(e) => props.onSave(e.currentTarget.checked)}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
{settingText(s()) === "true" ? "Enabled" : "Disabled"}
</label>
</Show>
<Show when={s().is_set}>
<button
class="btn btn-ghost btn-sm"
onClick={props.onReset}
disabled={props.pending}
title="Remove the in-app override — env/default applies again"
>
Reset
</button>
</Show>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
# @primora/mcp
Primora MCP stdio server - exposes the platform as `primora_*` tools to any
MCP client (Claude Desktop, Cursor, etc.).
## Build
```bash
npm run build --workspace @primora/mcp # → dist/server.js
```
## Configure
Point your MCP client at `node dist/server.js`. The server shares the CLI's
config file - `primora login` once and both tools work. Env vars override:
`PRIMORA_BASE_URL`, `PRIMORA_API_KEY`, `PRIMORA_ORG`, `PRIMORA_PROJECT`.
## Tools
30+ `primora_*` tools covering organizations, projects, buckets, objects,
collections, API keys, integrations, webhooks, scheduled jobs, telemetry,
and audit: `primora_whoami`, `primora_list_projects`,
`primora_upload_object`, `primora_query_audit`, `primora_run_job`, …
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+7 -4
View File
@@ -20,6 +20,10 @@ NGINX_PORT="${NGINX_PORT:-80}"
info() { echo "==> $*"; } info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; } die() { echo "ERROR: $*" >&2; exit 1; }
# '|' delimiter for sed: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
command -v docker >/dev/null 2>&1 || die "docker is not installed" command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed" docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed"
command -v curl >/dev/null 2>&1 || die "curl is not installed" command -v curl >/dev/null 2>&1 || die "curl is not installed"
@@ -42,10 +46,6 @@ if [ ! -f .env ]; then
ENCRYPTION_KEY=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 16) DB_PASSWORD=$(openssl rand -hex 16)
# '|' delimiter: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
"${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env "${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env
"${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env "${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env
"${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env "${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
@@ -68,6 +68,9 @@ if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \ && docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then && docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then
info "Starting prebuilt images" info "Starting prebuilt images"
# The source tree isn't downloaded on this path — strip build: blocks so a
# later `compose build`/`up --build` can't fail on missing apps/*/Dockerfile.
"${SED[@]}" '/^[[:space:]]*build:/,/^[[:space:]]*dockerfile:/d' docker-compose.yml
docker compose up -d --no-build docker compose up -d --no-build
else else
# GHCR packages are private until first published release — build from source. # GHCR packages are private until first published release — build from source.
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
Binary file not shown.
Binary file not shown.
+641
View File
@@ -0,0 +1,641 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Primora — Self-hosted backend platform</title>
<meta name="description" content="Primora is a self-hosted backend platform: auth, Postgres-backed APIs, S3-style object storage, JSON collections, scoped API keys, and a full audit log on your own hardware.">
<meta property="og:title" content="Primora — Self-hosted backend platform">
<meta property="og:description" content="Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware. No hosted tier.">
<meta property="og:type" content="website">
<meta property="og:image" content="screenshots/overview.png">
<link rel="icon" type="image/svg+xml" href="primora.svg">
<style>
@font-face {
font-family: "Geist";
src: url("fonts/geist-sans.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("fonts/geist-mono.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
:root {
--bg: #0a0a0b;
--bg-panel: #0f0f11;
--surface: #141416;
--surface-2: #1b1b1f;
--border: #222227;
--border-strong: #303036;
--text-1: #f4f4f5;
--text-2: #a2a2ab;
--text-3: #63636d;
--accent: #f4693a;
--accent-hover: #ff7c4e;
--accent-fg: #1c0d05;
--accent-muted: rgba(244, 105, 58, 0.12);
--accent-border: rgba(244, 105, 58, 0.35);
--success: #4cc38a;
--font-sans: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", monospace;
--radius: 8px;
--radius-lg: 12px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-sans);
font-size: 0.9375rem;
line-height: 1.6;
letter-spacing: -0.006em;
background: var(--bg);
color: var(--text-1);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection { background: var(--accent-border); color: var(--text-1); }
a { color: inherit; text-decoration: none; }
code, pre, .mono { font-family: var(--font-mono); }
.wrap { max-width: 72rem; margin: 0 auto; padding: 0 1.5rem; }
/* ------------------------------ nav ------------------------------ */
.nav {
position: fixed; inset: 0 0 auto 0; z-index: 50;
border-bottom: 1px solid var(--border);
background: rgba(10, 10, 11, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 3.5rem;
}
.nav-brand { display: flex; align-items: center; gap: 0.625rem; font-weight: 600; letter-spacing: -0.02em; }
.nav-brand img { width: 1.375rem; height: 1.375rem; border-radius: 5px; }
.nav-links { display: flex; align-items: center; gap: 1.75rem; font-size: 0.8125rem; color: var(--text-2); }
.nav-links a:hover { color: var(--text-1); }
.nav-cta {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.8125rem; font-weight: 500;
color: var(--text-1);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.375rem 0.75rem;
transition: border-color 0.15s, background 0.15s;
}
.nav-cta:hover { border-color: var(--accent-border); background: var(--accent-muted); color: var(--text-1); }
.nav-cta svg { width: 0.9375rem; height: 0.9375rem; }
@media (max-width: 720px) { .nav-links a:not(.nav-cta) { display: none; } }
/* ------------------------------ hero ------------------------------ */
.hero {
position: relative;
padding: 9.5rem 0 5rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.hero::before {
content: "";
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
pointer-events: none;
}
.hero-inner { position: relative; text-align: center; }
.eyebrow {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3125rem 0.875rem;
margin-bottom: 1.75rem;
}
.eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.hero h1 {
font-size: clamp(2.5rem, 6.5vw, 4.25rem);
font-weight: 600;
letter-spacing: -0.04em;
line-height: 1.05;
max-width: 17ch;
margin: 0 auto 1.25rem;
}
.hero h1 em { font-style: normal; color: var(--accent); }
.hero-sub {
font-size: 1.0625rem;
color: var(--text-2);
max-width: 34rem;
margin: 0 auto 2.5rem;
}
.hero-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: inherit;
font-size: 0.875rem; font-weight: 500;
border-radius: var(--radius);
padding: 0.625rem 1.125rem;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-primary { background: var(--accent); color: var(--accent-fg); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text-1); }
.btn-secondary:hover { border-color: var(--text-3); }
.btn svg { width: 1rem; height: 1rem; }
.install {
display: inline-flex; align-items: center; gap: 0.875rem;
margin-top: 2rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.625rem 0.625rem 1rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-2);
max-width: 100%;
}
.install .prompt { color: var(--accent); user-select: none; }
.install code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.install button {
flex: none;
display: inline-flex; align-items: center; gap: 0.375rem;
font-family: var(--font-mono); font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.375rem 0.625rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.install button:hover { color: var(--text-1); border-color: var(--border-strong); }
.install button.copied { color: var(--success); border-color: var(--success); }
.install button svg { width: 0.8125rem; height: 0.8125rem; }
@media (max-width: 560px) {
.install { font-size: 0.6875rem; }
}
/* --------------------------- screenshot --------------------------- */
.shot-section { padding: 0 0 5.5rem; border-bottom: 1px solid var(--border); }
.shot {
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
margin-top: -1px;
}
.shot-bar {
display: flex; align-items: center; gap: 0.375rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.shot-bar .led { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-3); }
.shot-bar .url {
margin-left: 0.75rem;
font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3);
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
padding: 0.1875rem 0.625rem;
}
.shot img { display: block; width: 100%; }
/* ---------------------------- sections ---------------------------- */
section.block { padding: 5.5rem 0; border-bottom: 1px solid var(--border); }
.section-head { margin-bottom: 3rem; max-width: 40rem; }
.section-head .kicker {
font-family: var(--font-mono); font-size: 0.6875rem;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--accent);
display: block; margin-bottom: 0.875rem;
}
.section-head h2 {
font-size: clamp(1.625rem, 3.5vw, 2.25rem);
font-weight: 600; letter-spacing: -0.03em; line-height: 1.15;
margin-bottom: 0.75rem;
}
.section-head p { color: var(--text-2); }
/* fact strip */
.facts {
display: grid; grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.fact { padding: 1.5rem; border-left: 1px solid var(--border); }
.fact:first-child { border-left: 0; }
.fact .v { font-family: var(--font-mono); font-size: 1.375rem; font-weight: 500; color: var(--text-1); font-variant-numeric: tabular-nums; }
.fact .v em { font-style: normal; color: var(--accent); }
.fact .k { font-size: 0.75rem; color: var(--text-3); margin-top: 0.25rem; }
@media (max-width: 720px) {
.facts { grid-template-columns: 1fr 1fr; }
.fact:nth-child(3) { border-left: 0; }
.fact:nth-child(n+3) { border-top: 1px solid var(--border); }
}
/* feature grid */
.grid {
display: grid; grid-template-columns: repeat(3, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.cell {
padding: 1.75rem;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
transition: background 0.15s;
}
.cell:hover { background: var(--surface); }
.cell:nth-child(3n+1) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 0; }
.cell .icon {
width: 2rem; height: 2rem;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--border-strong); border-radius: var(--radius);
color: var(--accent); margin-bottom: 1.125rem;
background: var(--accent-muted);
border-color: var(--accent-border);
}
.cell .icon svg { width: 1rem; height: 1rem; }
.cell h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; }
.cell p { font-size: 0.8125rem; color: var(--text-2); }
.cell .tag {
display: inline-block; margin-top: 0.875rem;
font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.08em;
color: var(--text-3); border: 1px solid var(--border); border-radius: 4px;
padding: 0.125rem 0.4375rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr 1fr; }
.cell:nth-child(3n+1) { border-left: 1px solid var(--border); }
.cell:nth-child(odd) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 1px solid var(--border); }
.cell:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 560px) {
.grid { grid-template-columns: 1fr; }
.cell { border-left: 0 !important; border-top: 1px solid var(--border) !important; }
.cell:first-child { border-top: 0 !important; }
}
/* architecture */
.arch {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel);
padding: 2rem;
overflow-x: auto;
}
.arch pre {
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.75;
color: var(--text-2);
min-width: 40rem;
}
.arch .hl { color: var(--accent); }
.arch .dim { color: var(--text-3); }
.arch .fg { color: var(--text-1); }
/* screenshots */
.shots { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.shot-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
transition: border-color 0.15s;
}
.shot-card:hover { border-color: var(--border-strong); }
.shot-card img { display: block; width: 100%; border-bottom: 1px solid var(--border); }
.shot-card figcaption {
display: flex; align-items: baseline; justify-content: space-between;
padding: 0.875rem 1.125rem;
}
.shot-card figcaption b { font-size: 0.8125rem; font-weight: 600; }
.shot-card figcaption span { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3); }
@media (max-width: 720px) { .shots { grid-template-columns: 1fr; } }
/* ecosystem */
.eco { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }
.eco-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel); padding: 1.75rem;
}
.eco-card h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; display: flex; align-items: center; gap: 0.5rem; }
.eco-card h3 svg { width: 1rem; height: 1rem; color: var(--accent); }
.eco-card p { font-size: 0.8125rem; color: var(--text-2); margin-bottom: 1rem; }
.eco-card code.snip {
display: block;
font-size: 0.75rem; color: var(--text-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 0.625rem 0.875rem;
overflow-x: auto; white-space: nowrap;
}
.eco-card code.snip .p { color: var(--accent); }
@media (max-width: 720px) { .eco { grid-template-columns: 1fr; } }
/* final cta */
.cta { text-align: center; padding: 6rem 0; border-bottom: 1px solid var(--border); }
.cta h2 { font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 600; letter-spacing: -0.03em; margin-bottom: 0.75rem; }
.cta p { color: var(--text-2); margin-bottom: 2rem; }
/* footer */
footer { padding: 2.5rem 0; }
.foot { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.foot .left { display: flex; align-items: center; gap: 0.625rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .left img { width: 1.125rem; height: 1.125rem; border-radius: 4px; }
.foot .links { display: flex; gap: 1.5rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .links a:hover { color: var(--text-1); }
</style>
</head>
<body>
<nav class="nav">
<div class="wrap nav-inner">
<a class="nav-brand" href="#top">
<img src="primora.svg" alt="Primora logo">
Primora
</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#stack">Stack</a>
<a href="#ecosystem">Ecosystem</a>
<a class="nav-cta" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
GitHub
</a>
</div>
</div>
</nav>
<header class="hero" id="top">
<div class="wrap hero-inner">
<span class="eyebrow"><span class="dot"></span>self-hosted &middot; MIT licensed</span>
<h1>Your backend, <em>your hardware</em>.</h1>
<p class="hero-sub">
Primora is a self-hosted backend platform — auth, Postgres-backed APIs,
S3-style object storage, JSON collections, scoped API keys, and a full
audit log behind one dashboard. No hosted tier.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Get started</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
View on GitHub
</a>
</div>
<div class="install" id="install">
<span class="prompt">$</span>
<code id="install-cmd">curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash</code>
<button id="copy-btn" type="button" aria-label="Copy install command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>copy</span>
</button>
</div>
</div>
</header>
<div class="shot-section">
<div class="wrap">
<figure class="shot">
<div class="shot-bar">
<span class="led"></span><span class="led"></span><span class="led"></span>
<span class="url">http://localhost</span>
</div>
<img src="screenshots/overview.png" alt="Primora project overview dashboard" loading="eager">
</figure>
</div>
</div>
<section class="block">
<div class="wrap">
<div class="facts">
<div class="fact"><div class="v"><em>1</em> cmd</div><div class="k">install — Docker Compose up</div></div>
<div class="fact"><div class="v"><em>0</em> hosted tiers</div><div class="k">data never leaves the box</div></div>
<div class="fact"><div class="v">MIT</div><div class="k">open source license</div></div>
<div class="fact"><div class="v"><em>100%</em> audit</div><div class="k">every mutation logged</div></div>
</div>
</div>
</section>
<section class="block" id="features">
<div class="wrap">
<div class="section-head">
<span class="kicker">// features</span>
<h2>Everything a backend needs. Nothing it doesn't.</h2>
<p>Inspired by the UX of Appwrite and Supabase — rebuilt as a single self-hosted stack you own outright.</p>
</div>
<div class="grid">
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></div>
<h3>Organizations &amp; projects</h3>
<p>Workspaces with scoped org and project roles. Invite members by link, assign owner/admin/developer.</p>
<span class="tag">rbac</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></div>
<h3>Auth</h3>
<p>Email/password via Better Auth, optional GitHub, Google, Discord and Microsoft OAuth. JWTs verified against JWKS.</p>
<span class="tag">better-auth + jwks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg></div>
<h3>Object storage</h3>
<p>S3-style buckets on your own filesystem, public or private. Upload, move, rename, inline preview.</p>
<span class="tag">local fs</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/><path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3"/></svg></div>
<h3>Collections</h3>
<p>Schema-flexible JSON documents stored in Postgres JSONB. Query, create and update without migrations.</p>
<span class="tag">postgres jsonb</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.6 7.6a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg></div>
<h3>Scoped API keys</h3>
<p><code class="mono">prm_&lt;prefix&gt;_&lt;secret&gt;</code> credentials, shown once at creation. Revocable, rate-limited per key.</p>
<span class="tag">prm_*</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg></div>
<h3>Audit log</h3>
<p>Every mutating request recorded — actor, action, target. Filterable in the UI, exportable as CSV or JSON.</p>
<span class="tag">csv / json export</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/></svg></div>
<h3>Automation</h3>
<p>Scheduled jobs and live event streams. Webhooks and connectors push project events to external systems.</p>
<span class="tag">schedules + webhooks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg></div>
<h3>Instance admin</h3>
<p>Sign-up policy, mail transport, and rate limits managed in-app — no restart, secrets stored AES-256-GCM.</p>
<span class="tag">settings → instance</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg></div>
<h3>Demo mode</h3>
<p>Fully client-side seeded workspace — append <code class="mono">?demo=true</code> to the URL, no sign-up required.</p>
<span class="tag">?demo=true</span>
</div>
</div>
</div>
</section>
<section class="block" id="stack">
<div class="wrap">
<div class="section-head">
<span class="kicker">// stack</span>
<h2>Boring on purpose.</h2>
<p>One Postgres, one API, one process boundary per concern. Everything ships in Docker Compose.</p>
</div>
<div class="arch">
<pre><span class="fg">Browser</span> <span class="dim">──▶</span> <span class="fg">Nginx</span> <span class="dim">──┬──▶</span> <span class="hl">Frontend</span> <span class="dim">SolidJS + Vite</span>
<span class="dim">├──▶</span> <span class="hl">Auth</span> <span class="dim">Better Auth + Hono</span>
<span class="dim">└──▶</span> <span class="hl">API</span> <span class="dim">Go + Gin</span>
<span class="dim">│</span>
<span class="dim">├──▶</span> <span class="fg">PostgreSQL</span> <span class="dim">primary datastore</span>
<span class="dim">├──▶</span> <span class="fg">DragonflyDB</span> <span class="dim">cache + rate limits</span>
<span class="dim">└──▶</span> <span class="fg">Local FS</span> <span class="dim">object storage</span></pre>
</div>
</div>
</section>
<section class="block" id="screenshots">
<div class="wrap">
<div class="section-head">
<span class="kicker">// screenshots</span>
<h2>The console, as shipped.</h2>
<p>Real UI, no mockups. Every screen is dark-first, hairline-divided, and keyboard-driven.</p>
</div>
<div class="shots">
<figure class="shot-card">
<img src="screenshots/storage.png" alt="Object storage buckets and files" loading="lazy">
<figcaption><b>Storage</b><span>buckets / objects</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/collections.png" alt="JSON document collections" loading="lazy">
<figcaption><b>Collections</b><span>jsonb documents</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/telemetry.png" alt="Telemetry and component health" loading="lazy">
<figcaption><b>Telemetry</b><span>component health</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation.png" alt="Scheduled automation jobs" loading="lazy">
<figcaption><b>Automation</b><span>schedules</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation-live.png" alt="Live automation events" loading="lazy">
<figcaption><b>Live events</b><span>real-time stream</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/audit.png" alt="Audit log with export" loading="lazy">
<figcaption><b>Audit log</b><span>csv / json export</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="block" id="ecosystem">
<div class="wrap">
<div class="section-head">
<span class="kicker">// ecosystem</span>
<h2>Beyond the browser.</h2>
<p>The same API powers a CLI, an MCP server, a desktop shell, and an installable PWA.</p>
</div>
<div class="eco">
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>CLI</h3>
<p><code class="mono">primora</code> manages orgs, projects, buckets, objects, keys, and audit from the terminal.</p>
<code class="snip"><span class="p">$</span> primora storage objects --bucket assets --limit 10</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>MCP server</h3>
<p>Exposes <code class="mono">primora_*</code> tools to MCP clients — drive the platform from an agent.</p>
<code class="snip">primora_list_projects · primora_upload_object · …</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>Desktop</h3>
<p>Tauri 2 shell that connects to your deployment, with tray presence and native notifications.</p>
<code class="snip">apps/desktop · tauri 2</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>PWA</h3>
<p>The dashboard installs on mobile and desktop browsers — same console, native frame.</p>
<code class="snip">installable · offline-aware shell</code>
</div>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<h2>Run it on your own box.</h2>
<p>Docker with the Compose plugin is the only prerequisite. Up in one command.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Install Primora</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora/releases" rel="noopener">Releases</a>
</div>
</div>
</section>
<footer>
<div class="wrap foot">
<div class="left">
<img src="primora.svg" alt="">
<span>Primora — MIT licensed</span>
</div>
<div class="links">
<a href="https://github.com/Dvorinka/Primora" rel="noopener">GitHub</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/QUICK_START.md" rel="noopener">Docs</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/CONTRIBUTING.md" rel="noopener">Contributing</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/SECURITY.md" rel="noopener">Security</a>
</div>
</div>
</footer>
<script>
document.getElementById("copy-btn").addEventListener("click", async function () {
const cmd = document.getElementById("install-cmd").textContent;
try {
await navigator.clipboard.writeText(cmd);
} catch {
const ta = document.createElement("textarea");
ta.value = cmd;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
this.classList.add("copied");
this.querySelector("span").textContent = "copied";
setTimeout(() => {
this.classList.remove("copied");
this.querySelector("span").textContent = "copy";
}, 1600);
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#f4693a"/>
<g fill="#ffffff">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "primora", "name": "primora",
"version": "0.6.0", "version": "0.7.1",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [
@@ -69,6 +69,9 @@ export type { HealthStatus } from './models/HealthStatus';
export { IngestEvent } from './models/IngestEvent'; export { IngestEvent } from './models/IngestEvent';
export type { IngestRequest } from './models/IngestRequest'; export type { IngestRequest } from './models/IngestRequest';
export type { IngestResponse } from './models/IngestResponse'; export type { IngestResponse } from './models/IngestResponse';
export type { InstancePublicConfig } from './models/InstancePublicConfig';
export { InstanceSetting } from './models/InstanceSetting';
export type { InstanceSettingsResponse } from './models/InstanceSettingsResponse';
export { Integration } from './models/Integration'; export { Integration } from './models/Integration';
export type { IntegrationAnalytics } from './models/IntegrationAnalytics'; export type { IntegrationAnalytics } from './models/IntegrationAnalytics';
export type { IntegrationID } from './models/IntegrationID'; export type { IntegrationID } from './models/IntegrationID';
@@ -115,6 +118,7 @@ export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectReque
export { UpdateBucketRequest } from './models/UpdateBucketRequest'; export { UpdateBucketRequest } from './models/UpdateBucketRequest';
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest'; export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest'; export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest'; export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest'; export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest'; export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
@@ -192,6 +196,9 @@ export { $HealthStatus } from './schemas/$HealthStatus';
export { $IngestEvent } from './schemas/$IngestEvent'; export { $IngestEvent } from './schemas/$IngestEvent';
export { $IngestRequest } from './schemas/$IngestRequest'; export { $IngestRequest } from './schemas/$IngestRequest';
export { $IngestResponse } from './schemas/$IngestResponse'; export { $IngestResponse } from './schemas/$IngestResponse';
export { $InstancePublicConfig } from './schemas/$InstancePublicConfig';
export { $InstanceSetting } from './schemas/$InstanceSetting';
export { $InstanceSettingsResponse } from './schemas/$InstanceSettingsResponse';
export { $Integration } from './schemas/$Integration'; export { $Integration } from './schemas/$Integration';
export { $IntegrationAnalytics } from './schemas/$IntegrationAnalytics'; export { $IntegrationAnalytics } from './schemas/$IntegrationAnalytics';
export { $IntegrationID } from './schemas/$IntegrationID'; export { $IntegrationID } from './schemas/$IntegrationID';
@@ -238,6 +245,7 @@ export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest'; export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest'; export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest'; export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest'; export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest'; export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest'; export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
@@ -257,6 +265,7 @@ export { AutomationService } from './services/AutomationService';
export { CollectionsService } from './services/CollectionsService'; export { CollectionsService } from './services/CollectionsService';
export { DatabasesService } from './services/DatabasesService'; export { DatabasesService } from './services/DatabasesService';
export { HealthService } from './services/HealthService'; export { HealthService } from './services/HealthService';
export { InstanceService } from './services/InstanceService';
export { IntegrationsService } from './services/IntegrationsService'; export { IntegrationsService } from './services/IntegrationsService';
export { OrganizationsService } from './services/OrganizationsService'; export { OrganizationsService } from './services/OrganizationsService';
export { PlatformService } from './services/PlatformService'; export { PlatformService } from './services/PlatformService';
@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type InstancePublicConfig = {
/**
* Whether public sign-up is open (bootstrap always allowed)
*/
signup_enabled: boolean;
/**
* True when no user exists yet — first sign-up becomes instance admin
*/
bootstrap_required: boolean;
/**
* Enabled OAuth providers — empty on self-hosted
*/
social_providers: Array<string>;
};
@@ -0,0 +1,31 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type InstanceSetting = {
key: string;
type: InstanceSetting.type;
secret: boolean;
/**
* True when an in-app override exists
*/
is_set: boolean;
source: InstanceSetting.source;
/**
* Effective value — never returned for secret keys
*/
value?: any;
};
export namespace InstanceSetting {
export enum type {
BOOL = 'bool',
INT = 'int',
STRING = 'string',
}
export enum source {
APP = 'app',
ENV = 'env',
DEFAULT = 'default',
}
}
@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { InstanceSetting } from './InstanceSetting';
export type InstanceSettingsResponse = {
settings: Array<InstanceSetting>;
};
@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateInstanceSettingRequest = {
/**
* JSON value matching the key's declared type
*/
value: any;
};
@@ -0,0 +1,25 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $InstancePublicConfig = {
properties: {
signup_enabled: {
type: 'boolean',
description: `Whether public sign-up is open (bootstrap always allowed)`,
isRequired: true,
},
bootstrap_required: {
type: 'boolean',
description: `True when no user exists yet — first sign-up becomes instance admin`,
isRequired: true,
},
social_providers: {
type: 'array',
contains: {
type: 'string',
},
isRequired: true,
},
},
} as const;
@@ -0,0 +1,34 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $InstanceSetting = {
properties: {
key: {
type: 'string',
isRequired: true,
},
type: {
type: 'Enum',
isRequired: true,
},
secret: {
type: 'boolean',
isRequired: true,
},
is_set: {
type: 'boolean',
description: `True when an in-app override exists`,
isRequired: true,
},
source: {
type: 'Enum',
isRequired: true,
},
value: {
description: `Effective value — never returned for secret keys`,
properties: {
},
},
},
} as const;
@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $InstanceSettingsResponse = {
properties: {
settings: {
type: 'array',
contains: {
type: 'InstanceSetting',
},
isRequired: true,
},
},
} as const;
@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $UpdateInstanceSettingRequest = {
properties: {
value: {
description: `JSON value matching the key's declared type`,
properties: {
},
isRequired: true,
},
},
} as const;
@@ -0,0 +1,88 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { InstancePublicConfig } from '../models/InstancePublicConfig';
import type { InstanceSetting } from '../models/InstanceSetting';
import type { InstanceSettingsResponse } from '../models/InstanceSettingsResponse';
import type { UpdateInstanceSettingRequest } from '../models/UpdateInstanceSettingRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class InstanceService {
/**
* Public instance state for the login page — no auth required
* @returns InstancePublicConfig Public instance configuration
* @throws ApiError
*/
public static getInstancePublic(): CancelablePromise<InstancePublicConfig> {
return __request(OpenAPI, {
method: 'GET',
url: '/instance/public',
});
}
/**
* List instance settings (platform admin only)
* @returns InstanceSettingsResponse Resolved instance settings
* @throws ApiError
*/
public static listInstanceSettings(): CancelablePromise<InstanceSettingsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/instance/settings',
errors: {
401: `Error response`,
403: `Error response`,
},
});
}
/**
* Set an instance setting override (platform admin only)
* @returns InstanceSetting Setting stored
* @throws ApiError
*/
public static updateInstanceSetting({
key,
requestBody,
}: {
key: string,
requestBody: UpdateInstanceSettingRequest,
}): CancelablePromise<InstanceSetting> {
return __request(OpenAPI, {
method: 'PUT',
url: '/instance/settings/{key}',
path: {
'key': key,
},
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Error response`,
403: `Error response`,
404: `Error response`,
},
});
}
/**
* Revert a setting to its env/default value (platform admin only)
* @returns void
* @throws ApiError
*/
public static deleteInstanceSetting({
key,
}: {
key: string,
}): CancelablePromise<void> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/instance/settings/{key}',
path: {
'key': key,
},
errors: {
403: `Error response`,
404: `Error response`,
},
});
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#f4693a"/>
<g fill="#ffffff">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B