Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bc23386b | ||
|
|
112cf84362 | ||
|
|
61ede5ee99 | ||
|
|
2fbde04563 | ||
|
|
9c118d41c2 | ||
|
|
c0c5e1cfcb | ||
|
|
28b10eee2b | ||
|
|
23868ef7c9 | ||
|
|
10c667dffd | ||
|
|
11aa82b459 | ||
|
|
9658e167ba | ||
|
|
e71db32885 | ||
|
|
c6166de886 | ||
|
|
1214108d6b | ||
|
|
4f66c72121 | ||
|
|
d3dc84028d | ||
|
|
591dedab2f |
No files matched your search
@@ -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=
|
||||||
|
|||||||
@@ -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}" '
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|
| Collections | Automation | Audit log |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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,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"
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`, …
|
||||||
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.vercel
|
||||||
|
.env*
|
||||||
@@ -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 · 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 & 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_<prefix>_<secret></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>
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |