994 lines
41 KiB
Go
994 lines
41 KiB
Go
// Package config provides configuration management for Kandev.
|
||
// It supports loading configuration from environment variables, config files, and defaults.
|
||
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kandev/kandev/internal/common/ports"
|
||
"github.com/kandev/kandev/internal/profiles"
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
// kandevHomeSubdir is the hidden directory name used for the Kandev root
|
||
// under the user's home directory (e.g. ~/.kandev). This is the single
|
||
// source of truth for the dotdir name; derived paths go through
|
||
// ResolvedHomeDir / ResolvedDataDir rather than re-constructing it.
|
||
const kandevHomeSubdir = ".kandev"
|
||
|
||
// Config holds all configuration sections for Kandev.
|
||
type Config struct {
|
||
// HomeDir is the root Kandev directory (e.g. ~/.kandev in prod, or
|
||
// <repo>/.kandev-dev during local development). When empty, falls back
|
||
// to ~/.kandev. All workspace artifacts (data, tasks, worktrees, repos)
|
||
// live under this root.
|
||
HomeDir string `mapstructure:"homeDir"`
|
||
Server ServerConfig `mapstructure:"server"`
|
||
Tasks TasksConfig `mapstructure:"tasks"`
|
||
Credentials CredentialsConfig `mapstructure:"credentials"`
|
||
Limits LimitsConfig `mapstructure:"limits"`
|
||
MessageQueue MessageQueueConfig `mapstructure:"messageQueue"`
|
||
Agentctl AgentctlConfig `mapstructure:"agentctl"`
|
||
Planning PlanningConfig `mapstructure:"planning"`
|
||
Observability ObservabilityConfig `mapstructure:"observability"`
|
||
Launcher LauncherConfig `mapstructure:"launcher"`
|
||
Database DatabaseConfig `mapstructure:"database"`
|
||
NATS NATSConfig `mapstructure:"nats"`
|
||
Events EventsConfig `mapstructure:"events"`
|
||
Docker DockerConfig `mapstructure:"docker"`
|
||
Agent AgentConfig `mapstructure:"agent"`
|
||
Auth AuthConfig `mapstructure:"auth"`
|
||
Logging LoggingConfig `mapstructure:"logging"`
|
||
RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"`
|
||
Worktree WorktreeConfig `mapstructure:"worktree"`
|
||
RepoClone RepoCloneConfig `mapstructure:"repoClone"`
|
||
Debug DebugConfig `mapstructure:"debug"`
|
||
Office OfficeConfig `mapstructure:"office"`
|
||
Features FeaturesConfig `mapstructure:"features"`
|
||
GitHubCredentialBroker GitHubCredentialBrokerConfig `mapstructure:"githubCredentialBroker"`
|
||
Executors ExecutorsConfig `mapstructure:"executors"`
|
||
Source ConfigSource `mapstructure:"-" json:"-"`
|
||
}
|
||
|
||
// TasksConfig contains task lifecycle startup limits.
|
||
type TasksConfig struct {
|
||
PreparationTimeout time.Duration `mapstructure:"preparationTimeout"`
|
||
// StallDetectionThreshold is the event-silence window after which the
|
||
// session reconciliation sweep classifies an active session with no live
|
||
// execution as stalled (issue #3712). The orphaned-session healing grace
|
||
// window is twice this value.
|
||
StallDetectionThreshold time.Duration `mapstructure:"stallDetectionThreshold"`
|
||
}
|
||
|
||
// CredentialsConfig contains operator-managed credential file settings.
|
||
type CredentialsConfig struct {
|
||
File string `mapstructure:"file"`
|
||
}
|
||
|
||
// ExecutorsConfig contains executor-related startup settings.
|
||
type ExecutorsConfig struct {
|
||
// SSHReachabilityIntervalSeconds is the raw configured value, not yet
|
||
// clamped into the reachability package's own 15-3600 bound: 0 disables
|
||
// the poller, and reachability.ClampInterval owns the rest of the
|
||
// normalization at construction time.
|
||
SSHReachabilityIntervalSeconds int `mapstructure:"sshReachabilityIntervalSeconds"`
|
||
}
|
||
|
||
// LimitsConfig contains process and protocol capacity limits.
|
||
type LimitsConfig struct {
|
||
GHMaxConcurrent int `mapstructure:"ghMaxConcurrent"`
|
||
GitMaxConcurrent int `mapstructure:"gitMaxConcurrent"`
|
||
LSPMaxConnections int `mapstructure:"lspMaxConnections"`
|
||
}
|
||
|
||
// MessageQueueConfig contains prompt queue limits.
|
||
type MessageQueueConfig struct {
|
||
MaxPerSession int `mapstructure:"maxPerSession"`
|
||
}
|
||
|
||
// AgentctlConfig contains settings owned by managed agentctl processes.
|
||
type AgentctlConfig struct {
|
||
IdleTimeout time.Duration `mapstructure:"idleTimeout"`
|
||
IdleReaperInterval time.Duration `mapstructure:"idleReaperInterval"`
|
||
NotificationQueueCapacity int `mapstructure:"notificationQueueCapacity"`
|
||
|
||
// RecoveryDeadline is the single global bound covering adoption,
|
||
// enumeration and reconstruction together during standalone agent
|
||
// survival re-tracking at backend startup.
|
||
RecoveryDeadline time.Duration `mapstructure:"recoveryDeadline"`
|
||
// RecoveryReadTimeout and RecoveryReadRetries bound each read of a
|
||
// reconstruction value from the adopted control server during re-tracking.
|
||
RecoveryReadTimeout time.Duration `mapstructure:"recoveryReadTimeout"`
|
||
RecoveryReadRetries int `mapstructure:"recoveryReadRetries"`
|
||
// UnownedPeriod is how long a standalone control server tolerates having
|
||
// no owning backend before it stops its instances and exits.
|
||
UnownedPeriod time.Duration `mapstructure:"unownedPeriod"`
|
||
// DetachedEventLimit bounds the per-instance retained-event count while
|
||
// a standalone control server has no owning backend attached.
|
||
DetachedEventLimit int `mapstructure:"detachedEventLimit"`
|
||
}
|
||
|
||
// PlanningConfig contains planning service timing settings.
|
||
type PlanningConfig struct {
|
||
CoalesceWindowMs int `mapstructure:"coalesceWindowMs"`
|
||
}
|
||
|
||
// ObservabilityConfig contains process-wide tracing settings.
|
||
type ObservabilityConfig struct {
|
||
OTLPEndpoint string `mapstructure:"otlpEndpoint"`
|
||
}
|
||
|
||
// LauncherConfig contains settings used by the native launcher. The backend
|
||
// reads the same values so a selected file remains one source of truth.
|
||
type LauncherConfig struct {
|
||
WebPort int `mapstructure:"webPort"`
|
||
HealthTimeoutMs int `mapstructure:"healthTimeoutMs"`
|
||
NoBrowser bool `mapstructure:"noBrowser"`
|
||
}
|
||
|
||
// GitHubCredentialBrokerConfig holds the externally reachable service URL
|
||
// used by managed remote executors. It is independent of GitHub App setup so
|
||
// PAT and named gh CLI workspaces can use remote executors.
|
||
type GitHubCredentialBrokerConfig struct {
|
||
PublicBaseURL string `mapstructure:"publicBaseUrl" json:"-"`
|
||
ReissueSigningKey string `mapstructure:"reissueSigningKey" json:"-"`
|
||
}
|
||
|
||
func (c GitHubCredentialBrokerConfig) validate() error {
|
||
if strings.TrimSpace(c.PublicBaseURL) == "" {
|
||
return nil
|
||
}
|
||
return validatePublicBaseURL("githubCredentialBroker.publicBaseUrl", c.PublicBaseURL)
|
||
}
|
||
|
||
func validatePublicBaseURL(field, raw string) error {
|
||
u, err := url.Parse(raw)
|
||
if err != nil || !u.IsAbs() || u.Hostname() == "" {
|
||
return fmt.Errorf("%s must be an absolute URL", field)
|
||
}
|
||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||
return fmt.Errorf("%s must not include credentials, query, or fragment", field)
|
||
}
|
||
if u.Scheme != "https" && (u.Scheme != "http" || !IsLoopbackHost(u.Hostname())) {
|
||
return fmt.Errorf("%s must use HTTPS outside loopback development", field)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// expandTilde expands a leading "~/" to the user's home directory.
|
||
// Returns the input unchanged if expansion is unnecessary or fails.
|
||
func expandTilde(p string) string {
|
||
if !strings.HasPrefix(p, "~/") {
|
||
return p
|
||
}
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return p
|
||
}
|
||
return filepath.Join(home, p[2:])
|
||
}
|
||
|
||
// ResolvedHomeDir returns the Kandev root directory — the parent of data,
|
||
// tasks, worktrees, repos, sessions, quick-chat and lsp-servers.
|
||
//
|
||
// Resolution order:
|
||
// 1. KANDEV_HOME_DIR (explicit override — e.g. /data in Docker, /kandev in K8s,
|
||
// or <repo>/.kandev-dev during local development).
|
||
// 2. ~/.kandev (default).
|
||
func (c *Config) ResolvedHomeDir() string {
|
||
if c.HomeDir != "" {
|
||
return expandTilde(c.HomeDir)
|
||
}
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
return kandevHomeSubdir
|
||
}
|
||
return filepath.Join(home, kandevHomeSubdir)
|
||
}
|
||
|
||
// ResolvedDataDir returns the base data directory (where the SQLite DB lives).
|
||
// Always <ResolvedHomeDir>/data — relocate via KANDEV_HOME_DIR, not a separate knob.
|
||
func (c *Config) ResolvedDataDir() string {
|
||
return filepath.Join(c.ResolvedHomeDir(), "data")
|
||
}
|
||
|
||
// defaultServerHost is the wildcard host the server binds to when no host is
|
||
// configured — every interface.
|
||
const defaultServerHost = "0.0.0.0"
|
||
|
||
// ServerConfig holds HTTP server configuration.
|
||
type ServerConfig struct {
|
||
// Host is the bind address. A single value (e.g. "127.0.0.1") behaves as
|
||
// it always has; a comma-separated list (e.g. "127.0.0.1,100.64.0.1")
|
||
// binds one listener per address. Empty means the wildcard default.
|
||
//
|
||
// Host carries the KANDEV_SERVER_HOST env override, so when it is set it
|
||
// wins over Hosts to preserve env-over-file precedence (see ResolvedBinds).
|
||
Host string `mapstructure:"host"`
|
||
// Hosts is the YAML-array form of Host, for config files that prefer an
|
||
// array to a comma-separated string. It is used only when Host is unset.
|
||
Hosts []string `mapstructure:"hosts"`
|
||
TrustedProxies []string `mapstructure:"trustedProxies"`
|
||
Port int `mapstructure:"port"`
|
||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||
WebInternalURL string `mapstructure:"webInternalUrl"`
|
||
// WebTitlePrefix prefixes the browser tab title as "<prefix> Kandev" (for
|
||
// example "TEST") so several Kandev instances are distinguishable in
|
||
// adjacent browser tabs. Empty keeps the plain "Kandev" title.
|
||
WebTitlePrefix string `mapstructure:"webTitlePrefix"`
|
||
}
|
||
|
||
// splitHosts splits a comma-separated host string, trimming whitespace and
|
||
// dropping empty entries.
|
||
func splitHosts(s string) []string {
|
||
parts := strings.Split(s, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalizeHost validates h and returns its canonical form plus whether it is a
|
||
// wildcard (binds every interface). IP addresses are canonicalized via
|
||
// net.ParseIP so equivalent forms dedupe (e.g. 0:0:0:0:0:0:0:1 and ::1); any
|
||
// unspecified IP (0.0.0.0, ::, or a longhand form) is treated as a wildcard.
|
||
// Hostnames are returned as-is. An invalid entry returns an error.
|
||
func normalizeHost(h string) (canonical string, wildcard bool, err error) {
|
||
if ip := net.ParseIP(h); ip != nil {
|
||
if ip.IsUnspecified() {
|
||
return ip.String(), true, nil
|
||
}
|
||
return ip.String(), false, nil
|
||
}
|
||
if isValidHostname(h) {
|
||
return h, false, nil
|
||
}
|
||
return "", false, fmt.Errorf("server bind host %q is not a valid IP address or hostname", h)
|
||
}
|
||
|
||
// isValidHostname reports whether h is a syntactically valid RFC 1123 hostname.
|
||
func isValidHostname(h string) bool {
|
||
if len(h) == 0 || len(h) > 253 {
|
||
return false
|
||
}
|
||
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
|
||
if !isValidHostnameLabel(label) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isValidHostnameLabel reports whether label is a valid single hostname label
|
||
// (letters, digits, and interior hyphens, 1–63 chars).
|
||
func isValidHostnameLabel(label string) bool {
|
||
if len(label) == 0 || len(label) > 63 {
|
||
return false
|
||
}
|
||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||
return false
|
||
}
|
||
for i := 0; i < len(label); i++ {
|
||
c := label[i]
|
||
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') &&
|
||
(c < '0' || c > '9') && c != '-' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// IsLoopbackHost reports whether h refers only to the local loopback
|
||
// interface. A wildcard (empty/0.0.0.0/::) is NOT loopback because it also
|
||
// binds routable interfaces. Unresolvable hostnames are treated as
|
||
// non-loopback (fail-closed) so callers gating on "any non-loopback bind"
|
||
// don't under-report exposure.
|
||
func IsLoopbackHost(h string) bool {
|
||
h = strings.TrimSpace(h)
|
||
if h == "" {
|
||
return false
|
||
}
|
||
if strings.EqualFold(h, "localhost") {
|
||
return true
|
||
}
|
||
if ip := net.ParseIP(h); ip != nil {
|
||
return ip.IsLoopback()
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ResolvedBinds returns the de-duplicated, validated list of hosts the HTTP
|
||
// server should bind to. server.host (comma-separated string) takes precedence
|
||
// over server.hosts (YAML array) when set, because Host carries the
|
||
// KANDEV_SERVER_HOST env override and env must beat a config-file server.hosts
|
||
// (env-over-file precedence, and the desktop loopback contract). server.hosts
|
||
// is used only when host is unset. Whitespace is trimmed and empty/duplicate
|
||
// entries dropped, and IP addresses are canonicalized so equivalent forms
|
||
// dedupe. Every entry is validated before the set is collapsed, so an invalid
|
||
// host is rejected regardless of its position relative to a wildcard. If any
|
||
// entry is a wildcard (empty/0.0.0.0/:: or an unspecified IP) the whole set
|
||
// collapses to that single wildcard, since it already binds every interface. An
|
||
// empty result falls back to the wildcard default.
|
||
func (c *ServerConfig) ResolvedBinds() ([]string, error) {
|
||
raw := splitHosts(c.Host)
|
||
if len(raw) == 0 {
|
||
for _, h := range c.Hosts {
|
||
raw = append(raw, splitHosts(h)...)
|
||
}
|
||
}
|
||
|
||
seen := make(map[string]struct{}, len(raw))
|
||
out := make([]string, 0, len(raw))
|
||
wildcard := ""
|
||
for _, h := range raw {
|
||
norm, isWild, err := normalizeHost(h)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if isWild {
|
||
if wildcard == "" {
|
||
wildcard = norm
|
||
}
|
||
continue
|
||
}
|
||
if _, dup := seen[norm]; dup {
|
||
continue
|
||
}
|
||
seen[norm] = struct{}{}
|
||
out = append(out, norm)
|
||
}
|
||
if wildcard != "" {
|
||
return []string{wildcard}, nil
|
||
}
|
||
if len(out) == 0 {
|
||
return []string{defaultServerHost}, nil
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// NonLoopbackBinds returns the resolved bind hosts that are not loopback-only.
|
||
// The sibling app-auth work uses this to decide whether to require auth (fail
|
||
// closed when the server is reachable off-box). A wildcard bind counts as
|
||
// non-loopback.
|
||
func (c *ServerConfig) NonLoopbackBinds() ([]string, error) {
|
||
binds, err := c.ResolvedBinds()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]string, 0, len(binds))
|
||
for _, h := range binds {
|
||
if !IsLoopbackHost(h) {
|
||
out = append(out, h)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// DatabaseConfig holds database connection configuration.
|
||
type DatabaseConfig struct {
|
||
Driver string `mapstructure:"driver"`
|
||
Path string `mapstructure:"path"`
|
||
Host string `mapstructure:"host"`
|
||
Port int `mapstructure:"port"`
|
||
User string `mapstructure:"user"`
|
||
Password string `mapstructure:"password"`
|
||
DBName string `mapstructure:"dbName"`
|
||
SSLMode string `mapstructure:"sslMode"`
|
||
MaxConns int `mapstructure:"maxConns"`
|
||
MinConns int `mapstructure:"minConns"`
|
||
}
|
||
|
||
// NATSConfig holds NATS messaging configuration.
|
||
type NATSConfig struct {
|
||
URL string `mapstructure:"url"`
|
||
ClusterID string `mapstructure:"clusterId"`
|
||
ClientID string `mapstructure:"clientId"`
|
||
MaxReconnects int `mapstructure:"maxReconnects"`
|
||
}
|
||
|
||
// EventsConfig holds event bus namespace configuration.
|
||
type EventsConfig struct {
|
||
// Namespace isolates queue-group subscribers across deployments/instances.
|
||
// Empty value means derive from runtime data identity.
|
||
Namespace string `mapstructure:"namespace"`
|
||
}
|
||
|
||
// DockerConfig holds Docker client configuration.
|
||
type DockerConfig struct {
|
||
// Enabled controls whether the Docker runtime is available for task execution.
|
||
// When true and Docker is accessible, tasks can use Docker-based executors.
|
||
// Default: true (Docker runtime is enabled if Docker is available)
|
||
Enabled bool `mapstructure:"enabled"`
|
||
Host string `mapstructure:"host"`
|
||
APIVersion string `mapstructure:"apiVersion"`
|
||
TLSVerify bool `mapstructure:"tlsVerify"`
|
||
DefaultNetwork string `mapstructure:"defaultNetwork"`
|
||
VolumeBasePath string `mapstructure:"volumeBasePath"`
|
||
}
|
||
|
||
// AuthConfig holds authentication configuration. Whether authentication is
|
||
// enforced is controlled by the `features.auth` runtime flag
|
||
// (KANDEV_FEATURES_AUTH), not here — these are only session-mechanics knobs.
|
||
type AuthConfig struct {
|
||
JWTSecret string `mapstructure:"jwtSecret"`
|
||
TokenDuration int `mapstructure:"tokenDuration"` // in seconds
|
||
|
||
// SessionTTLHours is the sliding lifetime of a browser session. A session
|
||
// is extended whenever it is used with less than TTL-24h remaining.
|
||
SessionTTLHours int `mapstructure:"sessionTTLHours"`
|
||
|
||
// CookieName is the session cookie base name. Empty (the default) means
|
||
// the service uses the "kandev_session" base and port-scopes it from the
|
||
// request host, isolating instances on one host (see
|
||
// auth.Service.CookieNameForRequest). Only override for unusual
|
||
// reverse-proxy setups that need a fixed cookie name; a custom value is
|
||
// used verbatim (never port-suffixed) and disables automatic isolation.
|
||
CookieName string `mapstructure:"cookieName"`
|
||
}
|
||
|
||
// OfficeConfig holds configuration for the office (autonomous agents) feature.
|
||
type OfficeConfig struct {
|
||
// JWTSigningKey is the HMAC key used to sign agent runtime JWTs.
|
||
// When empty, a random key is generated at startup — fine for dev, but
|
||
// means every restart invalidates outstanding agent tokens. Production
|
||
// deployments should set a stable value (e.g. via KANDEV_OFFICE_JWTSIGNINGKEY).
|
||
JWTSigningKey string `mapstructure:"jwtSigningKey"`
|
||
SchedulerTickMs int `mapstructure:"schedulerTickMs"`
|
||
|
||
// The following back the launch-safety ceilings/budgets
|
||
// (REQ-OFFICE-LAUNCH-SAFETY-001/003/004/005) and the backpressure
|
||
// gate-failure escalation threshold (REQ-OFFICE-BACKPRESSURE-003.5).
|
||
// Every one is boot-time-only, like SchedulerTickMs above: resolved
|
||
// once at startup and passed into the owning repository/service via
|
||
// its SetXxx method, not polled or overridable at runtime. A value
|
||
// below the documented minimum (1, or 1 for PromotionAgeMinutes) is
|
||
// replaced by that default and the resolved source is reported as
|
||
// SourceDefault (applyPositiveIntEnv's existing behavior), matching
|
||
// the "replaced by the default and logged at warn level" language the
|
||
// acceptance criteria use for an out-of-range operator override.
|
||
MaxConcurrentInstance int `mapstructure:"maxConcurrentInstance"`
|
||
MaxConcurrentWorkspace int `mapstructure:"maxConcurrentWorkspace"`
|
||
WorkspaceBudgetPerHour int `mapstructure:"workspaceBudgetPerHour"`
|
||
RoutineBudgetPerHour int `mapstructure:"routineBudgetPerHour"`
|
||
// PromotionAgeMinutes is REQ-OFFICE-BACKPRESSURE-002.1's age-based
|
||
// priority promotion period, in minutes (converted to
|
||
// runssqlite.ClaimSafetyLimits.PromotionAge, a time.Duration, at the
|
||
// wiring site — the catalog only carries plain ints).
|
||
PromotionAgeMinutes int `mapstructure:"promotionAgeMinutes"`
|
||
MaxCausationDepth int `mapstructure:"maxCausationDepth"`
|
||
SelfTriggerAllowance int `mapstructure:"selfTriggerAllowance"`
|
||
// SelfTriggerTotalAllowance is the reason-independent sibling of
|
||
// SelfTriggerAllowance (AC-OFFICE-LAUNCH-SAFETY-004.8): it may be
|
||
// configured below SelfTriggerAllowance, in which case it is the
|
||
// binding limit and the per-reason allowance becomes unreachable.
|
||
SelfTriggerTotalAllowance int `mapstructure:"selfTriggerTotalAllowance"`
|
||
GateFailureThreshold int `mapstructure:"gateFailureThreshold"`
|
||
}
|
||
|
||
// FeaturesConfig is the typed wire/config shape for runtime feature flags.
|
||
// Every new release toggle defaults to false so production binaries ship with
|
||
// new work hidden until a deployment explicitly opts in (env var, e.g.
|
||
// KANDEV_FEATURES_OFFICE=true). The runtimeflags package owns metadata and
|
||
// typed config bindings.
|
||
//
|
||
// The struct doubles as the wire shape for GET /api/v1/features — `json` tags
|
||
// keep the field names lowercase and the handler in helpers.go just calls
|
||
// `c.JSON(200, p.features)` so new fields are picked up automatically.
|
||
//
|
||
// See docs/decisions/0007-runtime-feature-flags.md for the pattern and rollout policy.
|
||
type FeaturesConfig struct {
|
||
// LSPBrowserContinuity gates runtime-owned language-server leases that stay
|
||
// connected across browser attachment loss. Off in every embedded profile.
|
||
LSPBrowserContinuity bool `mapstructure:"lsp_browser_continuity" json:"lspBrowserContinuity"`
|
||
|
||
// Office gates the autonomous-agent feature: backend service construction,
|
||
// HTTP/WS route registration, and frontend nav/route visibility.
|
||
Office bool `mapstructure:"office" json:"office"`
|
||
|
||
// Auth is the on/off switch for opt-in authentication and per-user
|
||
// workspaces. When on, every visitor must sign in (the first becomes the
|
||
// admin via a setup wizard) and workspaces are private per user; the
|
||
// Users/Account settings surfaces also become visible. Set via the
|
||
// runtime feature toggle KANDEV_FEATURES_AUTH (Settings > System > Feature
|
||
// Toggles). Off by default so kandev stays single-user until a deployment
|
||
// opts in.
|
||
Auth bool `mapstructure:"auth" json:"auth"`
|
||
|
||
// Canvases gates agent-authored plugin web applications and their task and
|
||
// workspace surfaces. It remains off in every shipped profile until the
|
||
// isolated runtime and lifecycle are ready for opt-in use.
|
||
Canvases bool `mapstructure:"canvases" json:"canvases"`
|
||
|
||
// MultiTenancy gates organizations: a tenant boundary above users, where
|
||
// every user belongs to exactly one org and cross-org reach is a bug
|
||
// rather than a permission level. It requires Auth; enabling it without
|
||
// authentication is refused at startup, because a tenant boundary with no
|
||
// identity behind it is not a boundary. Set via the runtime feature toggle
|
||
// KANDEV_FEATURES_MULTI_TENANCY. Off in every shipped profile.
|
||
MultiTenancy bool `mapstructure:"multi_tenancy" json:"multiTenancy"`
|
||
|
||
// DynamicAgentRouting gates dynamic profile configuration, execution
|
||
// routing, and the shared route health service. It is disabled in every
|
||
// embedded profile until the complete feature is ready.
|
||
DynamicAgentRouting bool `mapstructure:"dynamic_agent_routing" json:"dynamicAgentRouting"`
|
||
|
||
// ClaudeBackgroundPromptHandoff gates the high-risk experiment that lets a
|
||
// claude-acp session accept a successor prompt after an adapter-attested
|
||
// foreground handoff while background work remains live. It is off in every
|
||
// embedded profile and must fail closed for every other provider.
|
||
ClaudeBackgroundPromptHandoff bool `mapstructure:"claude_background_prompt_handoff" json:"claudeBackgroundPromptHandoff"`
|
||
|
||
// ClaudeMidTurnSteering gates the high-risk experiment that delivers operator
|
||
// input into a turn that is still generating, for an agent that advertised
|
||
// prompt queueing. It is independent of ClaudeBackgroundPromptHandoff: that
|
||
// flag covers the foreground-idle handoff, this one covers a foreground that
|
||
// has not yielded. Off in every embedded profile, and it remains the
|
||
// kill-switch after rollout because the agent-side fold is undocumented and
|
||
// can regress without notice.
|
||
ClaudeMidTurnSteering bool `mapstructure:"claude_mid_turn_steering" json:"claudeMidTurnSteering"`
|
||
|
||
// OfficeSessionIdentity keys an Office task's session identity on the run's
|
||
// own agent instead of the task's runner seat, and binds an agent's
|
||
// decision re-evaluation to its own calling session instead of the task's
|
||
// most-recently-started session. On in every embedded profile; it remains a
|
||
// high-risk, path-scoped change to durable session identity. A live
|
||
// (task_id, agent_profile_id) pair is guarded in-transaction on the office
|
||
// session creation path, not by a table-level constraint, and pre-existing
|
||
// duplicate rows stay safe by selection, not migration. The toggle remains a
|
||
// kill switch that restores runner-seat binding and task-active-session
|
||
// decision re-evaluation when disabled.
|
||
OfficeSessionIdentity bool `mapstructure:"office_session_identity" json:"officeSessionIdentity"`
|
||
|
||
// NeedsYouInbox gates the Needs-you Inbox: a workspace-scoped sidebar
|
||
// destination, independent of Office, listing exactly the answerable
|
||
// clarification bundles for the active workspace. Off in prod until the
|
||
// feature is user-ready.
|
||
NeedsYouInbox bool `mapstructure:"needs_you_inbox" json:"needsYouInbox"`
|
||
|
||
// AgentSurvival lets a worktree or local-executor agent session survive a
|
||
// backend restart by adopting its still-running standalone control server
|
||
// instead of killing it. Off in every embedded profile, and unavailable on
|
||
// Windows (survival trades the platform's kill-on-job-close safeguard for
|
||
// an adoption handshake, which is untested there).
|
||
AgentSurvival bool `mapstructure:"agent_survival" json:"agentSurvival"`
|
||
}
|
||
|
||
// LoggingConfig holds logging configuration.
|
||
type LoggingConfig struct {
|
||
Level string `mapstructure:"level"`
|
||
Format string `mapstructure:"format"`
|
||
}
|
||
|
||
// RepositoryDiscoveryConfig holds configuration for local repository scanning.
|
||
type RepositoryDiscoveryConfig struct {
|
||
Roots []string `mapstructure:"roots"`
|
||
MaxDepth int `mapstructure:"maxDepth"`
|
||
}
|
||
|
||
// WorktreeConfig holds Git worktree configuration for concurrent agent execution.
|
||
type WorktreeConfig struct {
|
||
Enabled bool `mapstructure:"enabled"` // Enable worktree mode
|
||
DefaultBranch string `mapstructure:"defaultBranch"` // Default base branch (default: main)
|
||
CleanupOnRemove bool `mapstructure:"cleanupOnRemove"` // Remove worktree directory on task deletion
|
||
FetchTimeoutSeconds int `mapstructure:"fetchTimeoutSeconds"` // Git fetch timeout before worktree creation
|
||
PullTimeoutSeconds int `mapstructure:"pullTimeoutSeconds"` // Git pull timeout before worktree creation
|
||
}
|
||
|
||
// RepoCloneConfig holds configuration for automatic repository cloning.
|
||
type RepoCloneConfig struct {
|
||
BasePath string `mapstructure:"basePath"` // Base directory for cloned repos (default: ~/.kandev/repos)
|
||
}
|
||
|
||
// DebugConfig holds debug/profiling configuration.
|
||
type DebugConfig struct {
|
||
// DevMode enables all developer-only endpoints (pprof, memory, debug export).
|
||
// Controlled via KANDEV_DEBUG_DEV_MODE env var. Default: false.
|
||
DevMode bool `mapstructure:"devMode"`
|
||
|
||
// PprofEnabled is a legacy alias — if set, it also enables DevMode.
|
||
// Controlled via KANDEV_DEBUG_PPROF_ENABLED env var. Default: false.
|
||
PprofEnabled bool `mapstructure:"pprofEnabled"`
|
||
}
|
||
|
||
// AgentConfig holds agent runtime configuration.
|
||
// Note: Runtime selection is now per-task based on executor type, not global.
|
||
// The Standalone runtime (agentctl) always runs as a core service.
|
||
// Docker runtime is available when docker.enabled=true.
|
||
type AgentConfig struct {
|
||
// StandaloneHost is the host where standalone agentctl is running (default: 127.0.0.1)
|
||
StandaloneHost string `mapstructure:"standaloneHost"`
|
||
|
||
// StandalonePort is the control port for standalone agentctl (default: 39429)
|
||
StandalonePort int `mapstructure:"standalonePort"`
|
||
|
||
// StandaloneAuthToken is the single credential that authenticates both
|
||
// the agentctl *control server* (instance create/list/delete, health,
|
||
// ownership rotate/confirm) and every per-instance agentctl server it
|
||
// supervises (/agent/stream, file tree, shell, ...), per design 01
|
||
// "Single driver" (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). After adopting
|
||
// a surviving control server it holds the freshly rotated credential
|
||
// (AC-EXECUTORS-CONTROL-OWNERSHIP-002); use it for every client built
|
||
// against this control server or any instance it supervises. Set at
|
||
// runtime after agentctl starts; not persisted in config files.
|
||
StandaloneAuthToken string `mapstructure:"-"`
|
||
|
||
// StandalonePID is the OS process id of the standalone agentctl control-server
|
||
// this backend spawned. Set at runtime after agentctl starts (from the
|
||
// launcher); not persisted in config files. Used as the host-local liveness
|
||
// handle recorded in executors_running.local_pid for local/standalone rows.
|
||
StandalonePID int `mapstructure:"-"`
|
||
}
|
||
|
||
// ReadTimeoutDuration returns the read timeout as a time.Duration.
|
||
func (s *ServerConfig) ReadTimeoutDuration() time.Duration {
|
||
return time.Duration(s.ReadTimeout) * time.Second
|
||
}
|
||
|
||
// WriteTimeoutDuration returns the write timeout as a time.Duration.
|
||
func (s *ServerConfig) WriteTimeoutDuration() time.Duration {
|
||
return time.Duration(s.WriteTimeout) * time.Second
|
||
}
|
||
|
||
// TokenDurationTime returns the token duration as a time.Duration.
|
||
func (a *AuthConfig) TokenDurationTime() time.Duration {
|
||
return time.Duration(a.TokenDuration) * time.Second
|
||
}
|
||
|
||
// detectDefaultLogFormat returns the appropriate log format based on environment.
|
||
// Returns "json" if running in Kubernetes or other production environments.
|
||
// Returns "text" for terminal/development use (human-readable console format).
|
||
func detectDefaultLogFormat() string {
|
||
// Check if running in Kubernetes
|
||
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
||
return "json"
|
||
}
|
||
|
||
// Check for explicit production environment
|
||
if env := os.Getenv("KANDEV_ENV"); env == "production" || env == "prod" {
|
||
return "json"
|
||
}
|
||
|
||
// Default to text format for terminal use (more readable than JSON)
|
||
return "text"
|
||
}
|
||
|
||
// setDefaults configures default values for all configuration options.
|
||
func setDefaults(v *viper.Viper) {
|
||
// Server defaults. Host defaults to empty (not the wildcard) so an unset
|
||
// host is distinguishable from an explicit one: ResolvedBinds falls back to
|
||
// server.hosts only when host is unset, and empty resolves to the wildcard
|
||
// default. This keeps KANDEV_SERVER_HOST winning over a config-file
|
||
// server.hosts.
|
||
v.SetDefault("server.host", "")
|
||
v.SetDefault("server.port", ports.Backend)
|
||
v.SetDefault("server.readTimeout", 30)
|
||
v.SetDefault("server.writeTimeout", 30)
|
||
v.SetDefault("server.webInternalUrl", "")
|
||
v.SetDefault("server.webTitlePrefix", "")
|
||
|
||
// HomeDir default — empty means resolve from KANDEV_HOME_DIR env or ~/.kandev
|
||
v.SetDefault("homeDir", "")
|
||
|
||
// Database defaults
|
||
v.SetDefault("database.driver", "sqlite")
|
||
v.SetDefault("database.path", "")
|
||
v.SetDefault("database.host", "localhost")
|
||
v.SetDefault("database.port", 5432)
|
||
v.SetDefault("database.user", "kandev")
|
||
v.SetDefault("database.password", "")
|
||
v.SetDefault("database.dbName", "kandev")
|
||
v.SetDefault("database.sslMode", "disable")
|
||
v.SetDefault("database.maxConns", 25)
|
||
v.SetDefault("database.minConns", 5)
|
||
|
||
// NATS defaults - empty URL means use in-memory event bus
|
||
v.SetDefault("nats.url", "")
|
||
v.SetDefault("nats.clusterId", "kandev-cluster")
|
||
v.SetDefault("nats.clientId", "kandev-client")
|
||
v.SetDefault("nats.maxReconnects", 10)
|
||
|
||
// Events defaults
|
||
v.SetDefault("events.namespace", "")
|
||
|
||
// Docker defaults — platform-aware host and volume path
|
||
v.SetDefault("docker.enabled", true) // Docker runtime enabled by default if Docker is available
|
||
v.SetDefault("docker.host", DefaultDockerHost())
|
||
v.SetDefault("docker.apiVersion", "") // Empty = auto-negotiate with daemon
|
||
v.SetDefault("docker.tlsVerify", false)
|
||
v.SetDefault("docker.defaultNetwork", "kandev-network")
|
||
v.SetDefault("docker.volumeBasePath", defaultDockerVolumePath())
|
||
|
||
// Agent defaults (runtime selection is now per-task based on executor type)
|
||
//
|
||
// 127.0.0.1 instead of "localhost": localhost resolution can select an IPv6
|
||
// loopback address before an IPv4 address. The explicit IPv4 loopback avoids
|
||
// address-resolution variance when host utility health checks run against
|
||
// agentctl bound IPv4-only and fail with
|
||
// "dial tcp [::1]:41001: connect: connection refused" whenever agentctl is
|
||
// bound IPv4-only (e.g. auth-disabled loopback binds), spinning the
|
||
// "host utility instance unhealthy; recreating" loop. The loopback
|
||
// address is explicit and unambiguous; operators with a non-loopback
|
||
// control plane can still override via KANDEV_AGENT_STANDALONE_HOST.
|
||
v.SetDefault("agent.standaloneHost", "127.0.0.1")
|
||
v.SetDefault("agent.standalonePort", ports.AgentCtl)
|
||
|
||
// Auth defaults. auth.cookieName defaults to empty on purpose: the auth
|
||
// service keeps the "kandev_session" base fallback internally and
|
||
// port-scopes it from the request host, so an empty default must not be
|
||
// seeded with a concrete name or the suffixing would never fire in
|
||
// production. A non-empty configured value is used verbatim.
|
||
v.SetDefault("auth.jwtSecret", "")
|
||
v.SetDefault("auth.tokenDuration", 3600) // 1 hour
|
||
v.SetDefault("auth.sessionTTLHours", 720) // 30 days, sliding
|
||
v.SetDefault("auth.cookieName", "")
|
||
|
||
// Office defaults
|
||
v.SetDefault("office.jwtSigningKey", "")
|
||
|
||
// Feature-flag defaults live in ./features.yaml (symlinked to
|
||
// apps/backend/internal/features/features.yaml). LoadWithPath applies
|
||
// them via features.ApplyDefaults after this function returns so the
|
||
// embedded YAML, not a Go literal, is the source of truth. Env vars
|
||
// (KANDEV_FEATURES_<NAME>) and the deployment's config.yaml still
|
||
// override. See docs/decisions/0007-runtime-feature-flags.md.
|
||
|
||
// Logging defaults
|
||
v.SetDefault("logging.level", "info")
|
||
v.SetDefault("logging.format", detectDefaultLogFormat())
|
||
|
||
// Repository discovery defaults
|
||
v.SetDefault("repositoryDiscovery.roots", []string{})
|
||
v.SetDefault("repositoryDiscovery.maxDepth", 5)
|
||
|
||
// Worktree defaults
|
||
v.SetDefault("worktree.enabled", true)
|
||
v.SetDefault("worktree.defaultBranch", "main")
|
||
v.SetDefault("worktree.cleanupOnRemove", true)
|
||
v.SetDefault("worktree.fetchTimeoutSeconds", 60)
|
||
v.SetDefault("worktree.pullTimeoutSeconds", 60)
|
||
|
||
// RepoClone defaults
|
||
v.SetDefault("repoClone.basePath", "")
|
||
|
||
// Debug defaults
|
||
v.SetDefault("debug.pprofEnabled", false)
|
||
}
|
||
|
||
// DefaultDockerHost returns the platform-appropriate Docker socket path.
|
||
// Respects DOCKER_HOST env var as override (standard Docker convention).
|
||
func DefaultDockerHost() string {
|
||
if host := os.Getenv("DOCKER_HOST"); host != "" {
|
||
return host
|
||
}
|
||
if runtime.GOOS == "windows" {
|
||
return "npipe:////./pipe/docker_engine"
|
||
}
|
||
return "unix:///var/run/docker.sock"
|
||
}
|
||
|
||
// defaultDockerVolumePath returns the platform-appropriate volume base path.
|
||
func defaultDockerVolumePath() string {
|
||
if runtime.GOOS == "windows" {
|
||
localAppData := os.Getenv("LOCALAPPDATA")
|
||
if localAppData == "" {
|
||
localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
|
||
}
|
||
return filepath.Join(localAppData, "kandev", "volumes")
|
||
}
|
||
return "/var/lib/kandev/volumes"
|
||
}
|
||
|
||
// Load reads configuration from environment variables, config file, and defaults.
|
||
// Environment variables use the prefix KANDEV_ with snake_case naming.
|
||
// Config file should be named config.yaml and placed in the current directory or /etc/kandev/.
|
||
func Load() (*Config, error) {
|
||
return LoadWithHome("")
|
||
}
|
||
|
||
// LoadWithHome reads the selected configuration while using homeDir as the
|
||
// bootstrap directory for home-file discovery. The override affects file
|
||
// selection only; environment variables still retain their normal precedence
|
||
// over values read from the selected file.
|
||
func LoadWithHome(homeDir string) (*Config, error) {
|
||
if path := strings.TrimSpace(os.Getenv(InternalConfigFileEnv)); path != "" {
|
||
return loadWithPath(path, homeDir)
|
||
}
|
||
return loadWithPath("", homeDir)
|
||
}
|
||
|
||
// LoadWithPath reads configuration from the specified path or default locations.
|
||
func LoadWithPath(configPath string) (*Config, error) {
|
||
return loadWithPath(configPath, "")
|
||
}
|
||
|
||
func loadWithPath(configPath, homeDir string) (*Config, error) {
|
||
v := viper.New()
|
||
|
||
profileDefaults, err := profiles.EnvironmentDefaults()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read profile defaults: %w", err)
|
||
}
|
||
|
||
// Set defaults next. setDefaults seeds non-feature config
|
||
// (server, database, logging, …); feature-flag defaults flow
|
||
// through EnvironmentDefaults and AutomaticEnv below.
|
||
setDefaults(v)
|
||
|
||
// Seed Viper's features.* keyspace from profiles.yaml so the
|
||
// typed Config struct populates correctly even in tests that
|
||
// bypass AutomaticEnv. AutomaticEnv still wins at runtime.
|
||
flags, err := profiles.FeatureFlagDefaults()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read feature flag defaults: %w", err)
|
||
}
|
||
for name, value := range flags {
|
||
v.SetDefault("features."+name, value == "true")
|
||
}
|
||
setProfileDefaults(v, profileDefaults)
|
||
|
||
// Configure environment variables
|
||
v.SetEnvPrefix("KANDEV")
|
||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||
v.AutomaticEnv()
|
||
|
||
bindCatalogEnvironment(v)
|
||
// camelCase key, so AutomaticEnv would expose the undocumented
|
||
// KANDEV_AUTH_COOKIENAME; the canonical override is KANDEV_AUTH_COOKIE_NAME.
|
||
_ = v.BindEnv("auth.cookieName", "KANDEV_AUTH_COOKIE_NAME")
|
||
_ = v.BindEnv(
|
||
"githubCredentialBroker.publicBaseUrl",
|
||
"KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL",
|
||
)
|
||
_ = v.BindEnv(
|
||
"githubCredentialBroker.reissueSigningKey",
|
||
"KANDEV_GITHUB_CREDENTIAL_BROKER_REISSUE_SIGNING_KEY",
|
||
)
|
||
|
||
selection, found, err := selectConfigFile(configPath, homeDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("select configuration file: %w", err)
|
||
}
|
||
if found {
|
||
if err := readSelectedConfig(v, selection); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
yamlKeys := configKeys(v)
|
||
envSnapshot := environmentSnapshot()
|
||
|
||
var cfg Config
|
||
if err := decodeConfig(v, &cfg); err != nil {
|
||
if found {
|
||
return nil, fmt.Errorf("error unmarshaling config file %q: %w", selection.path, err)
|
||
}
|
||
return nil, fmt.Errorf("error unmarshaling config: %w", err)
|
||
}
|
||
sources := applyStartupDefaultsAndEnvironment(&cfg, yamlKeys, profileDefaults, envSnapshot)
|
||
if err := applySurvivalRecoveryEnv(&cfg, envSnapshot, sources); err != nil {
|
||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||
}
|
||
warnings := inspectSecretPermissions(selection, v)
|
||
warnings = append(warnings, clampOfficeLaunchSafetyConfig(&cfg)...)
|
||
cfg.Source = buildConfigSource(selection, v, sources, warnings)
|
||
|
||
if err := validateStartupSettings(&cfg); err != nil {
|
||
if found {
|
||
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
|
||
}
|
||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||
}
|
||
|
||
if err := validate(&cfg); err != nil {
|
||
if found {
|
||
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
|
||
}
|
||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||
}
|
||
|
||
return &cfg, nil
|
||
}
|
||
|
||
// validate checks that all required configuration fields are set.
|
||
// In development mode (default), most fields are optional.
|
||
func validate(cfg *Config) error {
|
||
var errs []string
|
||
|
||
// Server validation - always required
|
||
if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 {
|
||
errs = append(errs, "server.port must be between 1 and 65535")
|
||
}
|
||
if _, err := cfg.Server.ResolvedBinds(); err != nil {
|
||
errs = append(errs, err.Error())
|
||
}
|
||
|
||
// Database validation. Normalize the driver in place so downstream
|
||
// case-sensitive comparisons (and the postgres-only branch below) see
|
||
// a canonical value.
|
||
cfg.Database.Driver = strings.ToLower(cfg.Database.Driver)
|
||
validDrivers := map[string]bool{"sqlite": true, "postgres": true}
|
||
if !validDrivers[cfg.Database.Driver] {
|
||
errs = append(errs, "database.driver must be one of: sqlite, postgres")
|
||
}
|
||
if cfg.Database.Driver == "postgres" {
|
||
if cfg.Database.Port <= 0 || cfg.Database.Port > 65535 {
|
||
errs = append(errs, "database.port must be between 1 and 65535")
|
||
}
|
||
if cfg.Database.User == "" {
|
||
errs = append(errs, "database.user is required for postgres driver")
|
||
}
|
||
if cfg.Database.DBName == "" {
|
||
errs = append(errs, "database.dbName is required for postgres driver")
|
||
}
|
||
validSSLModes := map[string]bool{
|
||
"disable": true, "require": true, "verify-ca": true, "verify-full": true,
|
||
}
|
||
if !validSSLModes[strings.ToLower(cfg.Database.SSLMode)] {
|
||
errs = append(errs, "database.sslMode must be one of: disable, require, verify-ca, verify-full")
|
||
}
|
||
}
|
||
|
||
// NATS validation - optional (uses in-memory event bus if not set)
|
||
// No validation needed - empty URL means use in-memory
|
||
|
||
// Docker validation - optional (agent features disabled if not available)
|
||
// No validation needed - will gracefully degrade
|
||
|
||
// Auth validation - generate random secret if not set (dev mode)
|
||
if cfg.Auth.JWTSecret == "" {
|
||
cfg.Auth.JWTSecret = generateDevSecret()
|
||
}
|
||
if cfg.Auth.TokenDuration <= 0 {
|
||
errs = append(errs, "auth.tokenDuration must be positive")
|
||
}
|
||
|
||
// Logging validation
|
||
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
|
||
if !validLevels[strings.ToLower(cfg.Logging.Level)] {
|
||
errs = append(errs, "logging.level must be one of: debug, info, warn, error")
|
||
}
|
||
validFormats := map[string]bool{"json": true, "text": true}
|
||
if !validFormats[strings.ToLower(cfg.Logging.Format)] {
|
||
errs = append(errs, "logging.format must be one of: json, text")
|
||
}
|
||
|
||
if cfg.RepositoryDiscovery.MaxDepth <= 0 {
|
||
errs = append(errs, "repositoryDiscovery.maxDepth must be positive")
|
||
}
|
||
|
||
if err := cfg.GitHubCredentialBroker.validate(); err != nil {
|
||
errs = append(errs, err.Error())
|
||
}
|
||
|
||
if len(errs) > 0 {
|
||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// DSN returns the PostgreSQL connection string.
|
||
func (d *DatabaseConfig) DSN() string {
|
||
return fmt.Sprintf(
|
||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||
)
|
||
}
|
||
|
||
// generateDevSecret generates a random secret for development mode.
|
||
func generateDevSecret() string {
|
||
// Use a fixed dev secret with a warning prefix
|
||
// In production, users should set KANDEV_AUTH_JWTSECRET
|
||
return "dev-secret-change-in-production-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||
}
|