8 Commits
Author SHA1 Message Date
Tomáš Dvořák 11aa82b459 Merge pull request #10 from Dvorinka/release/v0.7.0
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
chore: release v0.7.0
2026-09-21 17:58:18 +02:00
Tomas DvorakandDevin 9658e167ba chore: release v0.7.0
Generated with [Devin](https://devin.ai)

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

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

After

Width:  |  Height:  |  Size: 334 B