feat: instance administration, bootstrap admin, and managed-tier OAuth gate

Self-hosted instances are now email/password only — OAuth providers
(GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true,
reserved for the future hosted tier.

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 17:46:47 +02:00
1 parent 1214108d6b
commit c6166de886
36 files changed
+1729 -109

No files matched your search

+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])
}
}
}