Merge pull request #9 from Dvorinka/feat/instance-admin

feat: instance administration, bootstrap admin, managed-tier OAuth gate
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-21 17:53:03 +02:00
commit e71db32885
36 files changed
+1659 -39

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
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
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
FRONTEND_PORT=3000
@@ -42,6 +50,8 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080
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=
MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit
@@ -50,6 +60,7 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_SECURE=false
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
+9
View File
@@ -2,6 +2,15 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [Unreleased]
### 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
### Added
+24 -2
View File
@@ -61,10 +61,32 @@ The important ones:
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
| `DRAGONFLY_URL` | Cache/rate-limit store |
| `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 |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables |
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
| `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 |
### 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
```bash
+2 -1
View File
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
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";
const redisClient = createClient({
+34 -7
View File
@@ -1,16 +1,18 @@
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration";
import { Pool } from "pg";
import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js";
import { env } from "./env.js";
export const authPool = new Pool({
connectionString: env.DATABASE_URL,
});
const socialProviders = {
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
// Self-hosted instances are email/password only — the env keys are ignored
// unless the managed flag is set.
const socialProviders = env.PRIMORA_MANAGED
? {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? {
github: {
@@ -44,7 +46,8 @@ const socialProviders = {
},
}
: {}),
};
}
: {};
export const auth = betterAuth({
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: {
sendOnSignUp: 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_USER: 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_SECRET: 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),
SMTP_PORT: Number(parsed.data.SMTP_PORT),
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",
};
+31 -19
View File
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
import { Resend } from "resend";
import { env } from "./env.js";
import { getStringSetting } from "./settings.js";
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
const transporter =
!resend && env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: env.SMTP_USER
// Mail config resolves per send: in-app setting → env var. This keeps
// SMTP/Resend editable from the dashboard without restarting the service.
async function resolveMailConfig() {
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
if (resendApiKey) {
return { from, resendApiKey } as const;
}
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
return {
from,
smtp: host
? {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
host,
port: Number(portRaw) || 1025,
secure: secureRaw === "true",
auth: user ? { user, pass } : undefined,
}
: undefined,
})
: null;
} as const;
}
export async function sendTransactionalEmail(input: {
to: string;
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
text: 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({
from: env.MAIL_FROM,
from: cfg.from,
to: [input.to],
subject: input.subject,
text: input.text,
@@ -37,16 +50,15 @@ export async function sendTransactionalEmail(input: {
return;
}
if (!transporter) {
if (!("smtp" in cfg) || !cfg.smtp) {
throw new Error("No mail transport configured for auth service.");
}
await transporter.sendMail({
from: env.MAIL_FROM,
await nodemailer.createTransport(cfg.smtp).sendMail({
from: cfg.from,
to: input.to,
subject: input.subject,
text: input.text,
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)
}
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" {
gin.SetMode(gin.ReleaseMode)
@@ -126,10 +127,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
UserPerMinute: cfg.UserRateLimitPerMin,
},
RateLimitResolver: settings.RateLimitPerMinute,
}.ResolveActor())
handler := &handlers.HTTPHandler{
Platform: platform,
Settings: settings,
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
@@ -336,6 +336,14 @@ type CoreScheduledJobRun struct {
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 {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
@@ -46,6 +46,7 @@ type Querier interface {
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, 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)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
@@ -68,6 +69,7 @@ type Querier interface {
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, 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)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, 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)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]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)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, 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)
}
@@ -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 {
Platform *services.PlatformService
Settings *services.SettingsService
Validate *validator.Validate
Metrics *observability.Metrics
Readiness func(*gin.Context) map[string]any
@@ -33,6 +34,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1")
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.GET("/organizations", h.organizations)
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
Verifier *auth.Verifier
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 {
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
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 {
return func(c *gin.Context) {
requestID := c.Request.Header.Get("X-Request-ID")
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
if actor.UserID != nil {
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
}
c.Set(actorKey, actor)
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
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
}
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 {
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: Webhooks
- name: Automation
- name: Instance
paths:
/health/liveness:
get:
@@ -67,6 +68,87 @@ paths:
$ref: "#/components/schemas/MeResponse"
"401":
$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:
post:
tags: [Platform]
@@ -1952,6 +2034,54 @@ components:
type: string
required: [code, message]
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:
type: object
properties:
+80
View File
@@ -4,6 +4,7 @@ import {
CreateInvitationRequest,
CreateOrganizationRequest,
HealthService,
InstanceService,
OrganizationsService,
PlatformService,
ProjectsService,
@@ -18,6 +19,7 @@ import {
CopyBucketObjectRequest,
type Bucket,
type BucketObject,
type InstanceSetting,
type OrganizationSummary,
type ProjectSummary,
} from "@primora/api-client";
@@ -141,6 +143,7 @@ export default function App() {
const [memberMessage, setMemberMessage] = createSignal("");
const [apiKeySecret, setApiKeySecret] = createSignal("");
const [collectionsMessage, setCollectionsMessage] = createSignal("");
const [instanceMessage, setInstanceMessage] = createSignal("");
const [selectedOrganizationID, setSelectedOrganizationID] = 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(
() => session()?.data?.user.id ?? null,
async () => {
@@ -312,6 +356,7 @@ export default function App() {
const storagePending = createMemo(() => isPendingPrefix("storage-"));
const collectionPending = createMemo(() => isPendingPrefix("collection-"));
const documentPending = createMemo(() => isPendingPrefix("document-"));
const instancePending = createMemo(() => isPendingPrefix("instance-"));
const [apiKeys, { refetch: refetchAPIKeys }] = createResource(
() => 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") {
if (isDemo) {
setAuthMessage("Demo mode is active — social auth is simulated.");
@@ -1500,6 +1571,9 @@ export default function App() {
name={name()}
authMessage={authMessage()}
authPending={authPending()}
canSignUp={canSignUp()}
bootstrapRequired={instancePublic()?.bootstrap_required ?? false}
enabledProviders={enabledSocialProviders()}
onModeChange={setMode}
onEmailChange={setEmail}
onPasswordChange={setPassword}
@@ -1816,6 +1890,12 @@ export default function App() {
<Show when={activeView() === "settings"}>
<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()}
apiKeyName={apiKeyName()}
apiKeyScopes={apiKeyScopes()}
+14
View File
@@ -23,6 +23,7 @@ import type {
Document,
IntegrationAnalytics,
IntegrationTestResult,
InstanceSetting,
MeResponse,
MetricPoint,
OrganizationSummary,
@@ -608,6 +609,19 @@ class DemoService {
}));
}
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 listDocuments() { await this.delay(); return documents; }
+39 -6
View File
@@ -18,6 +18,12 @@ interface LoginPageProps {
name: string;
authMessage: string;
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;
onEmailChange: (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) {
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 (
<div class="auth-wrap">
@@ -70,16 +78,19 @@ export function LoginPage(props: LoginPageProps) {
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
</div>
<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>
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
{isSignIn()
? "Access your workspace"
: props.bootstrapRequired
? "The first account becomes the instance administrator"
: "One account for the whole workspace"}
</p>
</div>
{/* mode toggle */}
{/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */}
<Show when={!props.bootstrapRequired}>
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
<button
type="button"
@@ -90,6 +101,7 @@ export function LoginPage(props: LoginPageProps) {
>
Sign in
</button>
<Show when={props.canSignUp}>
<button
type="button"
role="tab"
@@ -99,7 +111,14 @@ export function LoginPage(props: LoginPageProps) {
>
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">
<Show when={!isSignIn()}>
@@ -143,17 +162,30 @@ export function LoginPage(props: LoginPageProps) {
>
<Show
when={props.authPending}
fallback={isSignIn() ? "Sign in" : "Create account"}
fallback={
isSignIn()
? "Sign in"
: props.bootstrapRequired
? "Create admin account"
: "Create account"
}
>
<span class="spinner" /> Working…
</Show>
</button>
</form>
<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>
<Show when={providers().length > 0}>
<div class="divider my-6">or continue with</div>
<div class="grid grid-cols-4 gap-2">
{socialProviders.map((p) => (
<div class={`grid gap-2 ${providers().length === 1 ? "grid-cols-1" : providers().length === 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{providers().map((p) => (
<button
type="button"
class="btn btn-secondary btn-icon w-full"
@@ -166,6 +198,7 @@ export function LoginPage(props: LoginPageProps) {
</button>
))}
</div>
</Show>
<Show when={props.onTryDemo}>
<div class="divider my-6" />
+183 -2
View File
@@ -1,5 +1,5 @@
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 { Modal, ModalFooter } from "../components/Modal";
import { Input } from "../components/Input";
@@ -25,6 +25,12 @@ const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
];
interface SettingsPageProps {
isPlatformAdmin: boolean;
instanceSettings?: InstanceSetting[];
instancePending: boolean;
instanceMessage: string;
onSaveInstanceSetting: (key: string, value: unknown) => void;
onResetInstanceSetting: (key: string) => void;
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeyScopes: string[];
@@ -50,8 +56,27 @@ interface SettingsPageProps {
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) {
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 [createOrgOpen, setCreateOrgOpen] = createSignal(false);
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
@@ -94,6 +119,14 @@ export function SettingsPage(props: SettingsPageProps) {
>
Organization
</button>
<Show when={props.isPlatformAdmin}>
<button
class={`tab ${tab() === "instance" ? "active" : ""}`}
onClick={() => setTab("instance")}
>
Instance
</button>
</Show>
</div>
{/* ---------------- API keys ---------------- */}
@@ -322,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
</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 */}
<Show when={props.apiKeySecret}>
<Modal
@@ -412,3 +492,104 @@ export function SettingsPage(props: SettingsPageProps) {
</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>
);
}
@@ -69,6 +69,9 @@ export type { HealthStatus } from './models/HealthStatus';
export { IngestEvent } from './models/IngestEvent';
export type { IngestRequest } from './models/IngestRequest';
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 type { IntegrationAnalytics } from './models/IntegrationAnalytics';
export type { IntegrationID } from './models/IntegrationID';
@@ -115,6 +118,7 @@ export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectReque
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
export type { UpdateDocumentRequest } from './models/UpdateDocumentRequest';
export type { UpdateInstanceSettingRequest } from './models/UpdateInstanceSettingRequest';
export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganizationMemberRoleRequest';
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
@@ -192,6 +196,9 @@ export { $HealthStatus } from './schemas/$HealthStatus';
export { $IngestEvent } from './schemas/$IngestEvent';
export { $IngestRequest } from './schemas/$IngestRequest';
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 { $IntegrationAnalytics } from './schemas/$IntegrationAnalytics';
export { $IntegrationID } from './schemas/$IntegrationID';
@@ -238,6 +245,7 @@ export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
export { $UpdateDocumentRequest } from './schemas/$UpdateDocumentRequest';
export { $UpdateInstanceSettingRequest } from './schemas/$UpdateInstanceSettingRequest';
export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizationMemberRoleRequest';
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
@@ -257,6 +265,7 @@ export { AutomationService } from './services/AutomationService';
export { CollectionsService } from './services/CollectionsService';
export { DatabasesService } from './services/DatabasesService';
export { HealthService } from './services/HealthService';
export { InstanceService } from './services/InstanceService';
export { IntegrationsService } from './services/IntegrationsService';
export { OrganizationsService } from './services/OrganizationsService';
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`,
},
});
}
}