diff --git a/.env.example b/.env.example index ca3aada..9ac12dd 100644 --- a/.env.example +++ b/.env.example @@ -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 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= diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff9614..b4033b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/QUICK_START.md b/QUICK_START.md index 6fa724a..eb1c077 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -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 diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 8b9e0f9..3dbd969 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -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({ diff --git a/apps/auth/src/lib/auth.ts b/apps/auth/src/lib/auth.ts index 1c08ca8..b23100c 100644 --- a/apps/auth/src/lib/auth.ts +++ b/apps/auth/src/lib/auth.ts @@ -1,50 +1,53 @@ 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 = { - ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET - ? { - github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - }, - } - : {}), - ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET - ? { - google: { - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - }, - } - : {}), - ...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET - ? { - discord: { - clientId: env.DISCORD_CLIENT_ID, - clientSecret: env.DISCORD_CLIENT_SECRET, - }, - } - : {}), - ...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET - ? { - microsoft: { - clientId: env.MICROSOFT_CLIENT_ID, - clientSecret: env.MICROSOFT_CLIENT_SECRET, - tenantId: env.MICROSOFT_TENANT_ID, - }, - } - : {}), -}; +// 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: { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + }, + } + : {}), + ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }, + } + : {}), + ...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET + ? { + discord: { + clientId: env.DISCORD_CLIENT_ID, + clientSecret: env.DISCORD_CLIENT_SECRET, + }, + } + : {}), + ...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET + ? { + microsoft: { + clientId: env.MICROSOFT_CLIENT_ID, + clientSecret: env.MICROSOFT_CLIENT_SECRET, + tenantId: env.MICROSOFT_TENANT_ID, + }, + } + : {}), + } + : {}; 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, diff --git a/apps/auth/src/lib/db.ts b/apps/auth/src/lib/db.ts new file mode 100644 index 0000000..aba9c82 --- /dev/null +++ b/apps/auth/src/lib/db.ts @@ -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, +}); diff --git a/apps/auth/src/lib/env.ts b/apps/auth/src/lib/env.ts index 8fe7dc7..501afd6 100644 --- a/apps/auth/src/lib/env.ts +++ b/apps/auth/src/lib/env.ts @@ -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", }; diff --git a/apps/auth/src/lib/mail.ts b/apps/auth/src/lib/mail.ts index e440a2f..e8d23ee 100644 --- a/apps/auth/src/lib/mail.ts +++ b/apps/auth/src/lib/mail.ts @@ -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 - ? { - user: env.SMTP_USER, - pass: env.SMTP_PASSWORD, - } - : undefined, - }) - : null; +// 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 + ? { + host, + port: Number(portRaw) || 1025, + secure: secureRaw === "true", + auth: user ? { user, pass } : undefined, + } + : undefined, + } 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, }); } - diff --git a/apps/auth/src/lib/settings.ts b/apps/auth/src/lib/settings.ts new file mode 100644 index 0000000..920a78e --- /dev/null +++ b/apps/auth/src/lib/settings.ts @@ -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(); + +export async function getSetting(key: string): Promise { + 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 { + 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 { + 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)"); +} diff --git a/apps/backend/db/migrations/00010_settings.sql b/apps/backend/db/migrations/00010_settings.sql new file mode 100644 index 0000000..f05c893 --- /dev/null +++ b/apps/backend/db/migrations/00010_settings.sql @@ -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; diff --git a/apps/backend/db/queries/settings.sql b/apps/backend/db/queries/settings.sql new file mode 100644 index 0000000..d9485ab --- /dev/null +++ b/apps/backend/db/queries/settings.sql @@ -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; diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 34530fa..f118103 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -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 { diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 0f93039..1b56e6c 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -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"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 5fe0a8a..ec3a9f1 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -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) } diff --git a/apps/backend/internal/database/db/settings.sql.go b/apps/backend/internal/database/db/settings.sql.go new file mode 100644 index 0000000..6b536e3 --- /dev/null +++ b/apps/backend/internal/database/db/settings.sql.go @@ -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 +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index d2050c4..dcb4de6 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -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) diff --git a/apps/backend/internal/handlers/instance.go b/apps/backend/internal/handlers/instance.go new file mode 100644 index 0000000..68d7969 --- /dev/null +++ b/apps/backend/internal/handlers/instance.go @@ -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) +} diff --git a/apps/backend/internal/middleware/context.go b/apps/backend/internal/middleware/context.go index 0a5111a..3242d35 100644 --- a/apps/backend/internal/middleware/context.go +++ b/apps/backend/internal/middleware/context.go @@ -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) { diff --git a/apps/backend/internal/repositories/core_repository.go b/apps/backend/internal/repositories/core_repository.go index 93bf48f..204f81f 100644 --- a/apps/backend/internal/repositories/core_repository.go +++ b/apps/backend/internal/repositories/core_repository.go @@ -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) +} diff --git a/apps/backend/internal/services/settings.go b/apps/backend/internal/services/settings.go new file mode 100644 index 0000000..1e379ec --- /dev/null +++ b/apps/backend/internal/services/settings.go @@ -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 "}, + "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 + } +} diff --git a/apps/backend/internal/services/settings_test.go b/apps/backend/internal/services/settings_test.go new file mode 100644 index 0000000..b45e58f --- /dev/null +++ b/apps/backend/internal/services/settings_test.go @@ -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]) + } + } +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index ed0ae66..5417520 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -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: diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 5938f8f..29ebeca 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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(); const [selectedProjectID, setSelectedProjectID] = createSignal(); @@ -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 => { + 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() { void saveInstanceSetting(key, value)} + onResetInstanceSetting={(key) => void resetInstanceSetting(key)} apiKeys={apiKeys()} apiKeyName={apiKeyName()} apiKeyScopes={apiKeyScopes()} diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index c2a5044..81178ea 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -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 { + 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 " }, + { 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; } diff --git a/apps/frontend/src/pages/LoginPage.tsx b/apps/frontend/src/pages/LoginPage.tsx index f3f5522..91b1d15 100644 --- a/apps/frontend/src/pages/LoginPage.tsx +++ b/apps/frontend/src/pages/LoginPage.tsx @@ -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 (
@@ -70,36 +78,47 @@ export function LoginPage(props: LoginPageProps) { Primora

- {isSignIn() ? "Sign in" : "Create your account"} + {isSignIn() ? "Sign in" : props.bootstrapRequired ? "Create the admin account" : "Create your account"}

{isSignIn() ? "Access your workspace" - : "One account for the whole workspace"} + : props.bootstrapRequired + ? "The first account becomes the instance administrator" + : "One account for the whole workspace"}

- {/* mode toggle */} -
- - -
+ {/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */} + +
+ + + + +
+
+ +
+ This instance has no users yet. Create the first account — it becomes the administrator. +
+
@@ -143,29 +162,43 @@ export function LoginPage(props: LoginPageProps) { > Working… -
or continue with
+ +

+ Public sign-up is closed on this instance. +

+
-
- {socialProviders.map((p) => ( - - ))} -
+ 0}> +
or continue with
+ +
+ {providers().map((p) => ( + + ))} +
+
diff --git a/apps/frontend/src/pages/SettingsPage.tsx b/apps/frontend/src/pages/SettingsPage.tsx index 96eb0e0..b894ada 100644 --- a/apps/frontend/src/pages/SettingsPage.tsx +++ b/apps/frontend/src/pages/SettingsPage.tsx @@ -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 = { + "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 + + +
{/* ---------------- API keys ---------------- */} @@ -322,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
+ {/* ---------------- Instance (platform admin) ---------------- */} + + +
{props.instanceMessage}
+
+ +

+ In-app values override the matching environment variables. Secrets are stored encrypted + and never shown back. +

+ + + {(group) => { + const rows = () => (props.instanceSettings ?? []).filter((s) => s.key.startsWith(group.prefix)); + return ( + 0}> +
+
+ {group.title} + {group.hint} +
+
+ + {(setting) => ( + props.onSaveInstanceSetting(setting.key, value)} + onReset={() => props.onResetInstanceSetting(setting.key)} + /> + )} + +
+
+
+ ); + }} +
+ + +
+
+

Loading instance settings

+
+
+
+ {/* API key secret modal — shown while App holds a fresh secret */} ); } + +/** 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 = { + 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 ( +
+
+
+ {SETTING_LABELS[s().key] ?? s().key} +
+ {s().key} +
+ + + {s().is_set ? "in-app" : s().source} + + + + setDraft(e.currentTarget.value)} + disabled={props.pending} + aria-label={SETTING_LABELS[s().key] ?? s().key} + /> + +
+ } + > + +
+ + + + + + ); +} diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 83f1dc5..eb17f8d 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -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'; diff --git a/packages/api-client/src/generated/models/InstancePublicConfig.ts b/packages/api-client/src/generated/models/InstancePublicConfig.ts new file mode 100644 index 0000000..ce0a3a1 --- /dev/null +++ b/packages/api-client/src/generated/models/InstancePublicConfig.ts @@ -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; +}; + diff --git a/packages/api-client/src/generated/models/InstanceSetting.ts b/packages/api-client/src/generated/models/InstanceSetting.ts new file mode 100644 index 0000000..01378f7 --- /dev/null +++ b/packages/api-client/src/generated/models/InstanceSetting.ts @@ -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', + } +} + diff --git a/packages/api-client/src/generated/models/InstanceSettingsResponse.ts b/packages/api-client/src/generated/models/InstanceSettingsResponse.ts new file mode 100644 index 0000000..c86fc39 --- /dev/null +++ b/packages/api-client/src/generated/models/InstanceSettingsResponse.ts @@ -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; +}; + diff --git a/packages/api-client/src/generated/models/UpdateInstanceSettingRequest.ts b/packages/api-client/src/generated/models/UpdateInstanceSettingRequest.ts new file mode 100644 index 0000000..57f251b --- /dev/null +++ b/packages/api-client/src/generated/models/UpdateInstanceSettingRequest.ts @@ -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; +}; + diff --git a/packages/api-client/src/generated/schemas/$InstancePublicConfig.ts b/packages/api-client/src/generated/schemas/$InstancePublicConfig.ts new file mode 100644 index 0000000..7f479cc --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InstancePublicConfig.ts @@ -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; diff --git a/packages/api-client/src/generated/schemas/$InstanceSetting.ts b/packages/api-client/src/generated/schemas/$InstanceSetting.ts new file mode 100644 index 0000000..d6f28f3 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InstanceSetting.ts @@ -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; diff --git a/packages/api-client/src/generated/schemas/$InstanceSettingsResponse.ts b/packages/api-client/src/generated/schemas/$InstanceSettingsResponse.ts new file mode 100644 index 0000000..0e6a838 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InstanceSettingsResponse.ts @@ -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; diff --git a/packages/api-client/src/generated/schemas/$UpdateInstanceSettingRequest.ts b/packages/api-client/src/generated/schemas/$UpdateInstanceSettingRequest.ts new file mode 100644 index 0000000..c1026ac --- /dev/null +++ b/packages/api-client/src/generated/schemas/$UpdateInstanceSettingRequest.ts @@ -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; diff --git a/packages/api-client/src/generated/services/InstanceService.ts b/packages/api-client/src/generated/services/InstanceService.ts new file mode 100644 index 0000000..d38a8dd --- /dev/null +++ b/packages/api-client/src/generated/services/InstanceService.ts @@ -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 { + 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 { + 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 { + 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 { + return __request(OpenAPI, { + method: 'DELETE', + url: '/instance/settings/{key}', + path: { + 'key': key, + }, + errors: { + 403: `Error response`, + 404: `Error response`, + }, + }); + } +}