Files

1 line
1.1 MiB
Plaintext

{"repo": "admont-ai/admont-ai-api", "files": {"internal/llm/deepseek.go": {"63e033fcc839825c0e68a14e8f8648eadcd61cb9": {"t": 1785746084.5007963, "n": 1322, "h": "ea7077cd0c519b63", "c": "package llm\n\nimport (\n\t\"context\"\n\n\t\"github.com/openai/openai-go/v3\"\n\t\"github.com/openai/openai-go/v3/option\"\n)\n\nvar DeepSeekDefaultModel = Model{ID: \"deepseek-chat\", Name: \"DeepSeek V3\"}\n\ntype DeepSeekProvider struct {\n\tclient openai.Client\n\tmaxTokens int64\n}\n\nfunc NewDeepSeekProvider(apiKey string, maxTokens int64) *DeepSeekProvider {\n\treturn &DeepSeekProvider{\n\t\tclient: openai.NewClient(\n\t\t\toption.WithBaseURL(\"https://api.deepseek.com\"),\n\t\t\toption.WithAPIKey(apiKey),\n\t\t),\n\t\tmaxTokens: maxTokens,\n\t}\n}\n\nfunc (p *DeepSeekProvider) Name() string { return \"deepseek\" }\nfunc (p *DeepSeekProvider) DefaultModel() Model { return DeepSeekDefaultModel }\n\nfunc (p *DeepSeekProvider) DoChat(ctx context.Context, model, systemPrompt string, messages []ChatMessage, maxTokens int64) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatChat(ctx, p.client, model, effectiveTokens(maxTokens, p.maxTokens), systemPrompt, messages, \"deepseek\")\n}\n\nfunc (p *DeepSeekProvider) Do(ctx context.Context, model, systemPrompt, userPrompt string, maxTokens int64) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatDo(ctx, p.client, model, effectiveTokens(maxTokens, p.maxTokens), systemPrompt, userPrompt, \"deepseek\")\n}\n"}, "e573a4ca7ab487e3179f1767fe0102be903584ae": {"t": 1785759749.9221709, "n": 1322, "h": "ea7077cd0c519b63", "c": "package llm\n\nimport (\n\t\"context\"\n\n\t\"github.com/openai/openai-go/v3\"\n\t\"github.com/openai/openai-go/v3/option\"\n)\n\nvar DeepSeekDefaultModel = Model{ID: \"deepseek-chat\", Name: \"DeepSeek V3\"}\n\ntype DeepSeekProvider struct {\n\tclient openai.Client\n\tmaxTokens int64\n}\n\nfunc NewDeepSeekProvider(apiKey string, maxTokens int64) *DeepSeekProvider {\n\treturn &DeepSeekProvider{\n\t\tclient: openai.NewClient(\n\t\t\toption.WithBaseURL(\"https://api.deepseek.com\"),\n\t\t\toption.WithAPIKey(apiKey),\n\t\t),\n\t\tmaxTokens: maxTokens,\n\t}\n}\n\nfunc (p *DeepSeekProvider) Name() string { return \"deepseek\" }\nfunc (p *DeepSeekProvider) DefaultModel() Model { return DeepSeekDefaultModel }\n\nfunc (p *DeepSeekProvider) DoChat(ctx context.Context, model, systemPrompt string, messages []ChatMessage, maxTokens int64) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatChat(ctx, p.client, model, effectiveTokens(maxTokens, p.maxTokens), systemPrompt, messages, \"deepseek\")\n}\n\nfunc (p *DeepSeekProvider) Do(ctx context.Context, model, systemPrompt, userPrompt string, maxTokens int64) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatDo(ctx, p.client, model, effectiveTokens(maxTokens, p.maxTokens), systemPrompt, userPrompt, \"deepseek\")\n}\n"}, "8b691ac17167649b1f27cd0d03d9213ff40622d0": {"t": 1785759751.108805, "n": 1807, "h": "544a99d94f6f7b7c", "c": "package llm\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/openai/openai-go/v3\"\n\t\"github.com/openai/openai-go/v3/option\"\n)\n\nvar DeepSeekDefaultModel = Model{ID: \"deepseek-chat\", Name: \"DeepSeek V3\"}\n\ntype DeepSeekProvider struct {\n\tclient openai.Client\n\tmaxTokens int64\n}\n\nfunc NewDeepSeekProvider(apiKey string, maxTokens int64) *DeepSeekProvider {\n\treturn &DeepSeekProvider{\n\t\tclient: openai.NewClient(\n\t\t\toption.WithBaseURL(\"https://api.deepseek.com\"),\n\t\t\toption.WithAPIKey(apiKey),\n\t\t),\n\t\tmaxTokens: maxTokens,\n\t}\n}\n\nfunc (p *DeepSeekProvider) Name() string { return \"deepseek\" }\nfunc (p *DeepSeekProvider) DefaultModel() Model { return DeepSeekDefaultModel }\n\nfunc (p *DeepSeekProvider) DoChat(ctx context.Context, model, systemPrompt string, messages []ChatMessage) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatChat(ctx, p.client, model, p.maxTokens, systemPrompt, messages, \"deepseek\")\n}\n\nfunc (p *DeepSeekProvider) Do(ctx context.Context, model, systemPrompt, userPrompt string) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\tresp, err := p.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{\n\t\tModel: openai.ChatModel(model),\n\t\tMaxCompletionTokens: openai.Int(p.maxTokens),\n\t\tMessages: []openai.ChatCompletionMessageParamUnion{\n\t\t\topenai.SystemMessage(systemPrompt),\n\t\t\topenai.UserMessage(userPrompt),\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn \"\", TokenUsage{}, fmt.Errorf(\"deepseek API call: %w\", err)\n\t}\n\n\tusage := TokenUsage{\n\t\tInputTokens: resp.Usage.PromptTokens,\n\t\tOutputTokens: resp.Usage.CompletionTokens,\n\t}\n\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", usage, fmt.Errorf(\"no choices in deepseek response\")\n\t}\n\n\treturn resp.Choices[0].Message.Content, usage, nil\n}\n"}, "98e8766595971b5d51031b866922974953befb75": {"t": 1785759752.3269877, "n": 1993, "h": "8e7da0930946089f", "c": "package llm\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/openai/openai-go/v3\"\n\t\"github.com/openai/openai-go/v3/option\"\n)\n\nvar DeepSeekModels = []Model{\n\t{ID: \"deepseek-chat\", Name: \"DeepSeek V3\"},\n\t{ID: \"deepseek-reasoner\", Name: \"DeepSeek R1\"},\n}\n\nvar DeepSeekDefaultModel = DeepSeekModels[0] // DeepSeek V3\n\ntype DeepSeekProvider struct {\n\tclient openai.Client\n\tmaxTokens int64\n}\n\nfunc NewDeepSeekProvider(apiKey string, maxTokens int64) *DeepSeekProvider {\n\treturn &DeepSeekProvider{\n\t\tclient: openai.NewClient(\n\t\t\toption.WithBaseURL(\"https://api.deepseek.com\"),\n\t\t\toption.WithAPIKey(apiKey),\n\t\t),\n\t\tmaxTokens: maxTokens,\n\t}\n}\n\nfunc (p *DeepSeekProvider) Name() string { return \"deepseek\" }\nfunc (p *DeepSeekProvider) Models() []Model { return DeepSeekModels }\nfunc (p *DeepSeekProvider) DefaultModel() Model { return DeepSeekDefaultModel }\n\nfunc (p *DeepSeekProvider) DoChat(ctx context.Context, model, systemPrompt string, messages []ChatMessage) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\treturn doOpenAICompatChat(ctx, p.client, model, p.maxTokens, systemPrompt, messages, \"deepseek\")\n}\n\nfunc (p *DeepSeekProvider) Do(ctx context.Context, model, systemPrompt, userPrompt string) (string, TokenUsage, error) {\n\tif model == \"\" {\n\t\tmodel = DeepSeekDefaultModel.ID\n\t}\n\tresp, err := p.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{\n\t\tModel: openai.ChatModel(model),\n\t\tMaxCompletionTokens: openai.Int(p.maxTokens),\n\t\tMessages: []openai.ChatCompletionMessageParamUnion{\n\t\t\topenai.SystemMessage(systemPrompt),\n\t\t\topenai.UserMessage(userPrompt),\n\t\t},\n\t})\n\tif err != nil {\n\t\treturn \"\", TokenUsage{}, fmt.Errorf(\"deepseek API call: %w\", err)\n\t}\n\n\tusage := TokenUsage{\n\t\tInputTokens: resp.Usage.PromptTokens,\n\t\tOutputTokens: resp.Usage.CompletionTokens,\n\t}\n\n\tif len(resp.Choices) == 0 {\n\t\treturn \"\", usage, fmt.Errorf(\"no choices in deepseek response\")\n\t}\n\n\treturn resp.Choices[0].Message.Content, usage, nil\n}\n"}}, "main.go": {"63e033fcc839825c0e68a14e8f8648eadcd61cb9": {"t": 1785746088.563104, "n": 58512, "h": "2871e16b20d30494", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else if c.Request.Method == http.MethodGet {\n\t\t\t// Read-only GETs (document fetches, etc.) are noisy — keep at debug.\n\t\t\tentry.Debug(\"request\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif lvl, err := log.ParseLevel(cfg.LogLevel); err == nil {\n\t\tlog.SetLevel(lvl)\n\t} else if cfg.LogLevel != \"\" {\n\t\tlog.WithField(\"log_level\", cfg.LogLevel).Warn(\"invalid log_level; using info\")\n\t}\n\n\t// Log the effective configuration (defaults + config file + env overrides),\n\t// with secret values redacted.\n\tlog.WithField(\"config\", fmt.Sprintf(\"%+v\", cfg.Redacted())).Info(\"loaded configuration\")\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "cc6cf080b4645f9267fb35a3cd68a5fadfb09272": {"t": 1785759750.4735532, "n": 58512, "h": "2871e16b20d30494", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else if c.Request.Method == http.MethodGet {\n\t\t\t// Read-only GETs (document fetches, etc.) are noisy — keep at debug.\n\t\t\tentry.Debug(\"request\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif lvl, err := log.ParseLevel(cfg.LogLevel); err == nil {\n\t\tlog.SetLevel(lvl)\n\t} else if cfg.LogLevel != \"\" {\n\t\tlog.WithField(\"log_level\", cfg.LogLevel).Warn(\"invalid log_level; using info\")\n\t}\n\n\t// Log the effective configuration (defaults + config file + env overrides),\n\t// with secret values redacted.\n\tlog.WithField(\"config\", fmt.Sprintf(\"%+v\", cfg.Redacted())).Info(\"loaded configuration\")\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "07c6eab7da98243232a6754ccd5ab04872fec235": {"t": 1785759752.0190065, "n": 58168, "h": "ca5cef915e697bfb", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\t// Log the effective configuration (defaults + config file + env overrides),\n\t// with secret values redacted.\n\tlog.WithField(\"config\", fmt.Sprintf(\"%+v\", cfg.Redacted())).Info(\"loaded configuration\")\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "3e54a94af11c72ccb469b18fd5fb32d69b5c631b": {"t": 1785759753.688755, "n": 58275, "h": "75dce1c9f8634e51", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\t// Log the effective configuration (defaults + config file + env overrides),\n\t// with secret values redacted.\n\tlog.WithField(\"config\", fmt.Sprintf(\"%+v\", cfg.Redacted())).Info(\"loaded configuration\")\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "5b1c2b41c93f9c2a33aadfcf5f079f1d636f07b4": {"t": 1785759756.1968744, "n": 58980, "h": "d37fc287e7757262", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\n// secretEnvSubstrings are case-insensitive name fragments whose env var values\n// are redacted when logging the environment at startup.\nvar secretEnvSubstrings = []string{\n\t\"SECRET\", \"PASS\", \"TOKEN\", \"KEY\", \"CREDENTIAL\", \"PRIVATE\", \"SALT\", \"DSN\", \"APIKEY\",\n}\n\nfunc isSecretEnv(name string) bool {\n\tup := strings.ToUpper(name)\n\tfor _, s := range secretEnvSubstrings {\n\t\tif strings.Contains(up, s) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// logEnvironment logs every environment variable at startup (sorted by name),\n// redacting the value of any whose name suggests it is sensitive.\nfunc logEnvironment() {\n\tenv := os.Environ()\n\tsort.Strings(env)\n\tfor _, e := range env {\n\t\tname, value, ok := strings.Cut(e, \"=\")\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif isSecretEnv(name) {\n\t\t\tvalue = \"[REDACTED]\"\n\t\t}\n\t\tlog.WithFields(log.Fields{\"name\": name, \"value\": value}).Info(\"environment variable\")\n\t}\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tlogEnvironment()\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "63fcfead2e952f13756b9dd631cc6773a9607769": {"t": 1785759757.6962812, "n": 58073, "h": "738b4e4c53e82349", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\tif bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.NewPassword)) == nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be different from the current password\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "079ac2aa29e56cce1c111bfe6384290826e7fee1": {"t": 1785759759.2699804, "n": 57853, "h": "d0058ac183170e7a", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Refresh the admin user cache so the cleared password_expired flag is reflected.\n\t\tadminHandler.ReloadUsers()\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "ae5b8605bbd0e9532ebb8beae5c7ce0e06797059": {"t": 1785759761.0364697, "n": 57739, "h": "7bc0a4a710d3c0b8", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\tauthenticator.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Forced password reset (expired password) and the public password policy.\n\t\tinternalGroup.POST(\"/reset-password\", middleware.RateLimit(loginLimiter), authHandler.ResetPassword)\n\t\tpasswordPolicy := cfg.InternalAuth.PasswordPolicy()\n\t\tinternalGroup.GET(\"/password-policy\", func(c *gin.Context) { c.JSON(http.StatusOK, passwordPolicy) })\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\tadminHandler.SetPasswordPolicy(cfg.InternalAuth.PasswordPolicy())\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif err := cfg.InternalAuth.PasswordPolicy().Validate(body.NewPassword); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: err.Error()}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "8de113175b6920e6d1bc013e7ff94e6feebee950": {"t": 1785759763.1092958, "n": 57231, "h": "b3668a148d65492d", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// Passkey (WebAuthn) support for internal users. A user-verified passkey is\n\t// multi-factor on its own, so a passkey login bypasses TOTP.\n\tif authenticator != nil {\n\t\trpID := cfg.InternalAuth.WebAuthnRPID\n\t\tif rpID == \"\" {\n\t\t\tif u, err := url.Parse(baseURL); err == nil {\n\t\t\t\trpID = u.Hostname()\n\t\t\t}\n\t\t}\n\t\torigins := cfg.InternalAuth.WebAuthnOrigins\n\t\tif len(origins) == 0 {\n\t\t\torigins = cfg.AllowedOrigins\n\t\t}\n\t\tif wm, err := auth.NewWebAuthnManager(db, rpID, \"Admont\", origins); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize passkey support — passkeys disabled\")\n\t\t} else {\n\t\t\tauthHandler.SetWebAuthn(wm)\n\t\t\tlog.WithFields(log.Fields{\"rp_id\": rpID, \"origins\": origins}).Info(\"passkey (WebAuthn) support enabled\")\n\t\t}\n\t}\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t\t// Passkey (discoverable / usernameless) login.\n\t\tinternalGroup.POST(\"/webauthn/login/begin\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginBegin)\n\t\tinternalGroup.POST(\"/webauthn/login/finish\", middleware.RateLimit(loginLimiter), authHandler.WebAuthnLoginFinish)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\n\t// Passkey management for the logged-in internal user.\n\tmeGroup.POST(\"/webauthn/register/begin\", authHandler.WebAuthnRegisterBegin)\n\tmeGroup.POST(\"/webauthn/register/finish\", authHandler.WebAuthnRegisterFinish)\n\tmeGroup.GET(\"/webauthn/credentials\", authHandler.WebAuthnList)\n\tmeGroup.PATCH(\"/webauthn/credentials/:id\", authHandler.WebAuthnRename)\n\tmeGroup.DELETE(\"/webauthn/credentials/:id\", authHandler.WebAuthnDelete)\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "196728f0ed0f875bdffb9a3c85a68765dbf31a54": {"t": 1785759764.6768916, "n": 55775, "h": "c56865c2746d0814", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tsearchHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tragHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "147a329288fa325e49f8d6272990bcd78d0c269f": {"t": 1785759766.5137029, "n": 55650, "h": "87f2002c39e2c8e5", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\tagentHandler := requesthandler.NewAgentRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers(), docPaths, searchIndexer)\n\tagentHandler.SetConversationStore(db.Conversations, summarizer)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tagentHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tagentHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\tfuegogin.Post(engine, repo, \"/agent\", agentHandler.Agent,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Agentic AI assistant with repo-scoped file tools\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "e573a4ca7ab487e3179f1767fe0102be903584ae": {"t": 1785759768.0249104, "n": 55163, "h": "686ff6e42f06769f", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/token-limits\", adminHandler.GetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Get per-action LLM token limits\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/token-limits\", adminHandler.SetLLMTokenLimits,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Set per-action LLM token limits\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// llmTokenLimitsKey is the settings key holding the per-action output-token\n// limits as JSON, e.g. {\"ask\":4096,\"generate\":32768,\"summarize\":1024,\"edit\":8192}.\nconst llmTokenLimitsKey = \"llm_token_limits\"\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\n\tif raw, err := db.GetSetting(context.Background(), llmTokenLimitsKey); err == nil && raw != \"\" {\n\t\tvar limits map[string]int64\n\t\tif err := json.Unmarshal([]byte(raw), &limits); err == nil {\n\t\t\tclient.SetActionLimits(limits)\n\t\t} else {\n\t\t\tlog.WithError(err).Warn(\"invalid llm_token_limits setting, ignoring\")\n\t\t}\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "a75832c5139538c30336d8089d633057d61a393c": {"t": 1785759769.4198568, "n": 54270, "h": "2f962a8ee3fd1773", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\t// Reasoning models (o-series, gpt-5.x) consume the completion budget\n\t\t// with reasoning tokens before emitting content — 4096 routinely\n\t\t// produces empty responses for them.\n\t\tmaxTokens = 16384\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "8b691ac17167649b1f27cd0d03d9213ff40622d0": {"t": 1785759771.214268, "n": 54089, "h": "1a620dea88d09e88", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterDefault(\"anthropic\", llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterDefault(\"deepseek\", llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterDefault(\"google\", llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterDefault(\"meta\", llm.MetaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"mistral\", llm.MistralDefaultModel)\n\tmodelRegistry.RegisterDefault(\"ollama\", llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterDefault(\"openai\", llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterDefault(\"perplexity\", llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterDefault(\"xai\", llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"/:name/models\", adminHandler.GetLLMProviderModels,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List available models of an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(provType, \"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tregistry.SetFavourites(provType, cfg.FavouriteModels)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "f1a5b7d056a6473ba207a2280913d66a35afad89": {"t": 1785759773.0659697, "n": 53952, "h": "f745d4094da78d61", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator, db.Users, cfg.ExternalAuth.SignupMode)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterFallback(\"anthropic\", llm.AnthropicModels, llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterFallback(\"deepseek\", llm.DeepSeekModels, llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterFallback(\"google\", llm.GoogleModels, llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterFallback(\"meta\", llm.MetaModels, llm.MetaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"mistral\", llm.MistralModels, llm.MistralDefaultModel)\n\tmodelRegistry.RegisterFallback(\"ollama\", llm.OllamaModels, llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"openai\", llm.OpenAIModels, llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterFallback(\"perplexity\", llm.PerplexityModels, llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterFallback(\"xai\", llm.XAIModels, llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\t// Refresh the admin user cache when social login creates/updates a user.\n\tauthHandler.SetUserChangeHook(adminHandler.ReloadUsers)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user (also denies a pending request)\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"/:provider/:email/approve\", adminHandler.ApproveExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Approve a pending external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(\"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "0b66d9e3ad7dc52859cbb8e2bce43a46c9b868bb": {"t": 1785759774.5703945, "n": 53553, "h": "2d851f9e0473dd47", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Internal-user auth is handled natively (see /auth/internal/* and the MCP\n\t// native login). External IdP providers, if any, are loaded from the DB below.\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tvar authenticator *auth.Authenticator\n\tif cfg.InternalAuth.Enabled {\n\t\tauthenticator = auth.NewAuthenticator(db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t}\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins, authenticator)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterFallback(\"anthropic\", llm.AnthropicModels, llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterFallback(\"deepseek\", llm.DeepSeekModels, llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterFallback(\"google\", llm.GoogleModels, llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterFallback(\"meta\", llm.MetaModels, llm.MetaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"mistral\", llm.MistralModels, llm.MistralDefaultModel)\n\tmodelRegistry.RegisterFallback(\"ollama\", llm.OllamaModels, llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"openai\", llm.OpenAIModels, llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterFallback(\"perplexity\", llm.PerplexityModels, llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterFallback(\"xai\", llm.XAIModels, llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Native internal-user authentication (password + TOTP), no external IdP.\n\tif cfg.InternalAuth.Enabled {\n\t\tloginLimiter := middleware.NewRateLimiter(cfg.InternalAuth.MaxFailedLogin * 4)\n\t\tinternalGroup := authGroup.Group(\"/internal\")\n\t\tinternalGroup.GET(\"/signup-status\", authHandler.InternalSignupStatus)\n\t\tinternalGroup.POST(\"/login\", middleware.RateLimit(loginLimiter), authHandler.InternalLogin)\n\t\tinternalGroup.POST(\"/totp\", middleware.RateLimit(loginLimiter), authHandler.InternalTOTP)\n\t\tinternalGroup.POST(\"/signup\", middleware.RateLimit(loginLimiter), authHandler.InternalSignup)\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetAuthenticator(authenticator)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(\"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "aa6a123c40547d8b08c50dd7f5344cd9b374356a": {"t": 1785759776.1954322, "n": 56347, "h": "b238e88b9636372b", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// setupHydra registers Hydra as an internal auth provider in both registries.\nfunc setupHydra(ctx context.Context, db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) error {\n\thydraClientID, _ := db.GetSetting(ctx, \"hydra_client_id\")\n\thydraClientSecret, _ := db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\n\tredirectURIs := []string{httpCallbackURL, mcpCallbackURL}\n\thydraClient, err := auth.EnsureHydraClient(ctx, cfg.InternalAuth.AdminURL, hydraClientID, hydraClientSecret, redirectURIs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Persist client credentials if newly created.\n\tif hydraClientID != hydraClient.ClientID {\n\t\t_ = db.SetSetting(ctx, \"hydra_client_id\", hydraClient.ClientID)\n\t}\n\tif hydraClientSecret != hydraClient.ClientSecret && hydraClient.ClientSecret != \"\" {\n\t\t_ = db.SetEncryptedSetting(ctx, \"hydra_client_secret\", hydraClient.ClientSecret)\n\t\thydraClientSecret = hydraClient.ClientSecret\n\t}\n\tif hydraClientSecret == \"\" {\n\t\thydraClientSecret, _ = db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\t}\n\n\thttpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, httpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra HTTP provider: %w\", err)\n\t}\n\thttpRegistry.Register(httpHydra)\n\n\tmcpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, mcpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra MCP provider: %w\", err)\n\t}\n\tmcpRegistry.Register(mcpHydra)\n\n\tlog.Info(\"Hydra internal auth provider configured\")\n\treturn nil\n}\n\n// retryHydraSetup retries Hydra registration in the background until it succeeds.\nfunc retryHydraSetup(db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) {\n\tdelays := []time.Duration{10 * time.Second, 15 * time.Second, 30 * time.Second, 60 * time.Second}\n\tfor i, delay := range delays {\n\t\ttime.Sleep(delay)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).WithField(\"attempt\", i+2).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n\t// Keep retrying every 60s indefinitely.\n\tfor {\n\t\ttime.Sleep(60 * time.Second)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Register Hydra as the internal auth provider directly from service config.\n\tif cfg.InternalAuth.Enabled {\n\t\tif cfg.InternalAuth.PublicURL == \"\" || cfg.InternalAuth.AdminURL == \"\" {\n\t\t\tlog.Fatal(\"internal_auth.public_url and internal_auth.admin_url must be set when internal_auth.enabled is true\")\n\t\t}\n\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\t// Non-fatal: start without internal auth, retry in background.\n\t\t\tlog.WithError(err).Warn(\"Hydra not ready — internal auth disabled, retrying in background\")\n\t\t\tgo retryHydraSetup(db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry)\n\t\t}\n\t}\n\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService, cfg.AllowedOrigins)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterFallback(\"anthropic\", llm.AnthropicModels, llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterFallback(\"deepseek\", llm.DeepSeekModels, llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterFallback(\"google\", llm.GoogleModels, llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterFallback(\"meta\", llm.MetaModels, llm.MetaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"mistral\", llm.MistralModels, llm.MistralDefaultModel)\n\tmodelRegistry.RegisterFallback(\"ollama\", llm.OllamaModels, llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"openai\", llm.OpenAIModels, llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterFallback(\"perplexity\", llm.PerplexityModels, llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterFallback(\"xai\", llm.XAIModels, llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs, repoHandler.PermResolvers())\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs, repoHandler.PermResolvers())\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\t// Trusted proxies govern whether client-supplied X-Forwarded-For / -Proto\n\t// headers are honored. Trusting all proxies (the Gin default) lets clients\n\t// spoof their IP and bypass IP-based rate limiting and login brute-force\n\t// protection. Default to trusting none; deployments behind a known reverse\n\t// proxy must set trusted_proxies explicitly.\n\tif len(cfg.TrustedProxies) > 0 {\n\t\tif err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {\n\t\t\tlog.WithError(err).Fatal(\"invalid trusted_proxies configuration\")\n\t\t}\n\t} else {\n\t\t_ = r.SetTrustedProxies(nil)\n\t}\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\trefreshLimiter := middleware.NewRateLimiter(20)\n\tauthGroup.POST(\"/refresh\", middleware.RateLimit(refreshLimiter), authHandler.Refresh)\n\tauthGroup.POST(\"/exchange\", authHandler.Exchange)\n\n\t// Hydra login/consent flow (only when Hydra is enabled)\n\tif cfg.InternalAuth.Enabled {\n\t\thydraLoginHandler := auth.NewHydraLoginHandler(cfg.InternalAuth.AdminURL, db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\thydraGroup := r.Group(\"/hydra\")\n\t\thydraGroup.GET(\"/login\", hydraLoginHandler.LoginGet)\n\t\thydraGroup.POST(\"/login\", hydraLoginHandler.LoginPost)\n\t\thydraGroup.GET(\"/consent\", hydraLoginHandler.Consent)\n\t\thydraGroup.GET(\"/logout\", func(c *gin.Context) {\n\t\t\tc.Redirect(http.StatusFound, \"/\")\n\t\t})\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\tadminHandler.SetSessionInvalidator(jwtService.InvalidateSessions)\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\t// Invalidate all existing access/refresh tokens for this user so a\n\t\t// previously-issued (or stolen) token cannot outlive the password change.\n\t\tif id, ok := gc.Get(middleware.CtxUserIdentity); ok {\n\t\t\tif identity, ok := id.(string); ok {\n\t\t\t\tjwtService.InvalidateSessions(identity)\n\t\t\t}\n\t\t}\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tsearchRateLimiter := middleware.NewRateLimiter(60)\n\tsearchGroup := repos.Group(\"\", middleware.RateLimit(searchRateLimiter))\n\tfuegogin.Post(engine, searchGroup, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, searchGroup, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(\"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "f063d63e325a0f7dd2d5a33da93cb085b7f57972": {"t": 1785759777.8533754, "n": 55061, "h": "be9c775b5f568b08", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// setupHydra registers Hydra as an internal auth provider in both registries.\nfunc setupHydra(ctx context.Context, db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) error {\n\thydraClientID, _ := db.GetSetting(ctx, \"hydra_client_id\")\n\thydraClientSecret, _ := db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\n\tredirectURIs := []string{httpCallbackURL, mcpCallbackURL}\n\thydraClient, err := auth.EnsureHydraClient(ctx, cfg.InternalAuth.AdminURL, hydraClientID, hydraClientSecret, redirectURIs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Persist client credentials if newly created.\n\tif hydraClientID != hydraClient.ClientID {\n\t\t_ = db.SetSetting(ctx, \"hydra_client_id\", hydraClient.ClientID)\n\t}\n\tif hydraClientSecret != hydraClient.ClientSecret && hydraClient.ClientSecret != \"\" {\n\t\t_ = db.SetEncryptedSetting(ctx, \"hydra_client_secret\", hydraClient.ClientSecret)\n\t\thydraClientSecret = hydraClient.ClientSecret\n\t}\n\tif hydraClientSecret == \"\" {\n\t\thydraClientSecret, _ = db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\t}\n\n\thttpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, httpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra HTTP provider: %w\", err)\n\t}\n\thttpRegistry.Register(httpHydra)\n\n\tmcpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, mcpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra MCP provider: %w\", err)\n\t}\n\tmcpRegistry.Register(mcpHydra)\n\n\tlog.Info(\"Hydra internal auth provider configured\")\n\treturn nil\n}\n\n// retryHydraSetup retries Hydra registration in the background until it succeeds.\nfunc retryHydraSetup(db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) {\n\tdelays := []time.Duration{10 * time.Second, 15 * time.Second, 30 * time.Second, 60 * time.Second}\n\tfor i, delay := range delays {\n\t\ttime.Sleep(delay)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).WithField(\"attempt\", i+2).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n\t// Keep retrying every 60s indefinitely.\n\tfor {\n\t\ttime.Sleep(60 * time.Second)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tif os.Getenv(\"GIN_MODE\") == \"release\" {\n\t\t\tlog.Error(\"SECURITY: ADMONT_ENCRYPTION_KEY not set in release mode — encryption key derived from jwt_secret; set a dedicated encryption key for production\")\n\t\t} else {\n\t\t\tlog.Warn(\"ADMONT_ENCRYPTION_KEY not set — encryption key derived from jwt_secret; set a dedicated key for production\")\n\t\t}\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Register Hydra as the internal auth provider directly from service config.\n\tif cfg.InternalAuth.Enabled {\n\t\tif cfg.InternalAuth.PublicURL == \"\" || cfg.InternalAuth.AdminURL == \"\" {\n\t\t\tlog.Fatal(\"internal_auth.public_url and internal_auth.admin_url must be set when internal_auth.enabled is true\")\n\t\t}\n\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\t// Non-fatal: start without internal auth, retry in background.\n\t\t\tlog.WithError(err).Warn(\"Hydra not ready — internal auth disabled, retrying in background\")\n\t\t\tgo retryHydraSetup(db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry)\n\t\t}\n\t}\n\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 1*time.Hour)\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterFallback(\"anthropic\", llm.AnthropicModels, llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterFallback(\"deepseek\", llm.DeepSeekModels, llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterFallback(\"google\", llm.GoogleModels, llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterFallback(\"meta\", llm.MetaModels, llm.MetaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"mistral\", llm.MistralModels, llm.MistralDefaultModel)\n\tmodelRegistry.RegisterFallback(\"ollama\", llm.OllamaModels, llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"openai\", llm.OpenAIModels, llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterFallback(\"perplexity\", llm.PerplexityModels, llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterFallback(\"xai\", llm.XAIModels, llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs)\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs)\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\tr.Use(func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"DENY\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tif c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\" {\n\t\t\tc.Header(\"Strict-Transport-Security\", \"max-age=31536000; includeSubDomains\")\n\t\t}\n\t\tc.Next()\n\t})\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\tauthGroup.POST(\"/refresh\", authHandler.Refresh)\n\n\t// Hydra login/consent flow (only when Hydra is enabled)\n\tif cfg.InternalAuth.Enabled {\n\t\thydraLoginHandler := auth.NewHydraLoginHandler(cfg.InternalAuth.AdminURL, db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\thydraGroup := r.Group(\"/hydra\")\n\t\thydraGroup.GET(\"/login\", hydraLoginHandler.LoginGet)\n\t\thydraGroup.POST(\"/login\", hydraLoginHandler.LoginPost)\n\t\thydraGroup.GET(\"/consent\", hydraLoginHandler.Consent)\n\t\thydraGroup.GET(\"/logout\", func(c *gin.Context) {\n\t\t\tc.Redirect(http.StatusFound, \"/\")\n\t\t})\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tfuegogin.Post(engine, repos, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, repos, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(\"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}, "98e8766595971b5d51031b866922974953befb75": {"t": 1785759779.336964, "n": 54372, "h": "afab7cc11e5cc5e5", "c": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"image/png\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/christianfischer/md-wiki-server/config\"\n\t\"github.com/christianfischer/md-wiki-server/internal/auth\"\n\t\"github.com/christianfischer/md-wiki-server/internal/checker\"\n\t\"github.com/christianfischer/md-wiki-server/internal/draft\"\n\t\"github.com/christianfischer/md-wiki-server/internal/llm\"\n\twikimcp \"github.com/christianfischer/md-wiki-server/internal/mcp\"\n\t\"github.com/christianfischer/md-wiki-server/internal/middleware\"\n\t\"github.com/christianfischer/md-wiki-server/internal/permissions\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend\"\n\tpgvectorbackend \"github.com/christianfischer/md-wiki-server/internal/pg_vector/backend/pgvector\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/embedder\"\n\t\"github.com/christianfischer/md-wiki-server/internal/pg_vector/indexer\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/repofactory\"\n\t\"github.com/christianfischer/md-wiki-server/internal/repo/s3backend\"\n\trequesthandler \"github.com/christianfischer/md-wiki-server/internal/request_handler\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store\"\n\tstoreauth \"github.com/christianfischer/md-wiki-server/internal/store/auth_provider\"\n\t\"github.com/christianfischer/md-wiki-server/internal/store/git_repo\"\n\tllm_provider \"github.com/christianfischer/md-wiki-server/internal/store/llm_provider\"\n\tstoresearch \"github.com/christianfischer/md-wiki-server/internal/store/search_provider\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"golang.org/x/crypto/bcrypt\"\n\n\t\"github.com/gin-contrib/cors\"\n\t\"github.com/gin-contrib/gzip\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-fuego/fuego\"\n\t\"github.com/go-fuego/fuego/extra/fuegogin\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\ntype healthResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype meDetailsResponse struct {\n\tEmail string `json:\"email\"`\n\tName string `json:\"name\"`\n\tProvider string `json:\"provider\"`\n\tRoles []string `json:\"roles\"`\n\tPasswordExpired bool `json:\"password_expired,omitempty\"`\n}\n\ntype messageResponse struct {\n\tMessage string `json:\"message\"`\n}\n\ntype mcpClientResponse struct {\n\tID string `json:\"id\"`\n\tName string `json:\"name\"`\n\tConnectedAt string `json:\"connected_at\"`\n}\n\nvar silentPaths = map[string]bool{\n\t\"/me/mcp-clients\": true,\n}\n\nfunc ginLogrus() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tstart := time.Now()\n\t\tpath := c.Request.URL.Path\n\t\tif c.Request.URL.RawQuery != \"\" {\n\t\t\tpath = path + \"?\" + c.Request.URL.RawQuery\n\t\t}\n\n\t\tc.Next()\n\n\t\tif silentPaths[c.Request.URL.Path] && c.Writer.Status() < 400 {\n\t\t\treturn\n\t\t}\n\n\t\tentry := log.WithFields(log.Fields{\n\t\t\t\"status\": c.Writer.Status(),\n\t\t\t\"method\": c.Request.Method,\n\t\t\t\"path\": path,\n\t\t\t\"latency\": time.Since(start).Round(time.Millisecond),\n\t\t\t\"ip\": c.ClientIP(),\n\t\t})\n\n\t\tif len(c.Errors) > 0 {\n\t\t\tentry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())\n\t\t} else if c.Writer.Status() >= 500 {\n\t\t\tentry.Error(\"server error\")\n\t\t} else if c.Writer.Status() >= 400 {\n\t\t\tentry.Warn(\"client error\")\n\t\t} else {\n\t\t\tentry.Info(\"request\")\n\t\t}\n\t}\n}\n\n// setupHydra registers Hydra as an internal auth provider in both registries.\nfunc setupHydra(ctx context.Context, db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) error {\n\thydraClientID, _ := db.GetSetting(ctx, \"hydra_client_id\")\n\thydraClientSecret, _ := db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\n\tredirectURIs := []string{httpCallbackURL, mcpCallbackURL}\n\thydraClient, err := auth.EnsureHydraClient(ctx, cfg.InternalAuth.AdminURL, hydraClientID, hydraClientSecret, redirectURIs)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Persist client credentials if newly created.\n\tif hydraClientID != hydraClient.ClientID {\n\t\t_ = db.SetSetting(ctx, \"hydra_client_id\", hydraClient.ClientID)\n\t}\n\tif hydraClientSecret != hydraClient.ClientSecret && hydraClient.ClientSecret != \"\" {\n\t\t_ = db.SetEncryptedSetting(ctx, \"hydra_client_secret\", hydraClient.ClientSecret)\n\t\thydraClientSecret = hydraClient.ClientSecret\n\t}\n\tif hydraClientSecret == \"\" {\n\t\thydraClientSecret, _ = db.GetEncryptedSetting(ctx, \"hydra_client_secret\")\n\t}\n\n\thttpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, httpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra HTTP provider: %w\", err)\n\t}\n\thttpRegistry.Register(httpHydra)\n\n\tmcpHydra, err := auth.NewHydraProvider(cfg.InternalAuth.PublicURL, hydraClient.ClientID, hydraClientSecret, mcpCallbackURL)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating Hydra MCP provider: %w\", err)\n\t}\n\tmcpRegistry.Register(mcpHydra)\n\n\tlog.Info(\"Hydra internal auth provider configured\")\n\treturn nil\n}\n\n// retryHydraSetup retries Hydra registration in the background until it succeeds.\nfunc retryHydraSetup(db *store.Store, cfg *config.Config, httpCallbackURL, mcpCallbackURL string, httpRegistry, mcpRegistry *auth.Registry) {\n\tdelays := []time.Duration{10 * time.Second, 15 * time.Second, 30 * time.Second, 60 * time.Second}\n\tfor i, delay := range delays {\n\t\ttime.Sleep(delay)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).WithField(\"attempt\", i+2).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n\t// Keep retrying every 60s indefinitely.\n\tfor {\n\t\ttime.Sleep(60 * time.Second)\n\t\tctx := context.Background()\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\tlog.WithError(err).Warn(\"Hydra still not ready\")\n\t\t\tcontinue\n\t\t}\n\t\treturn\n\t}\n}\n\n// loadPermissions reads the permissions file through the backend abstraction.\nfunc loadPermissions(backend repo.RepoBackend) (*permissions.Resolver, error) {\n\tdata, err := backend.GetFile(\"\", permissions.PermissionsFileName)\n\tif err != nil {\n\t\treturn nil, nil // file doesn't exist\n\t}\n\treturn permissions.LoadFromData(data)\n}\n\n// savePermissionsToBackend writes the permissions file through the backend abstraction.\nfunc savePermissionsToBackend(backend repo.RepoBackend, r *permissions.Resolver) error {\n\tdata, err := permissions.Marshal(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn backend.AddFile(\"\", permissions.PermissionsFileName, data)\n}\n\nfunc main() {\n\tlog.SetFormatter(&log.TextFormatter{\n\t\tFullTimestamp: true,\n\t\tTimestampFormat: \"2006-01-02 15:04:05\",\n\t})\n\n\tcfg, err := config.Load()\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to load config: %v\", err)\n\t}\n\n\tif len(cfg.AllowedOrigins) == 0 {\n\t\tlog.Fatal(\"allowed_origins must be set in config\")\n\t}\n\tif cfg.RepoClonePath == \"\" {\n\t\tlog.Fatal(\"repo_clone_path must be set in config\")\n\t}\n\tif !filepath.IsAbs(cfg.RepoClonePath) {\n\t\tlog.Fatalf(\"repo_clone_path must be an absolute path, got: %s\", cfg.RepoClonePath)\n\t}\n\tif cwd, err := os.Getwd(); err == nil {\n\t\tabsClone, _ := filepath.Abs(cfg.RepoClonePath)\n\t\tif absClone == cwd || strings.HasPrefix(cwd, absClone+string(os.PathSeparator)) || strings.HasPrefix(absClone, cwd+string(os.PathSeparator)) {\n\t\t\tlog.Fatalf(\"git_repo.clone_path (%s) must not overlap with working directory (%s)\", absClone, cwd)\n\t\t}\n\t}\n\tif cfg.Database.DB == \"\" {\n\t\tlog.Fatal(\"database.db must be set in config\")\n\t}\n\n\t// --- Database setup ---\n\tctx := context.Background()\n\tdsn := cfg.Database.DSN()\n\n\tif err := store.EnsureDatabase(ctx, dsn); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to ensure database exists\")\n\t}\n\n\tdb, err := store.New(ctx, dsn)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to connect to database\")\n\t}\n\tdefer db.Close()\n\n\tif err := db.RunMigrations(); err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to run database migrations\")\n\t}\n\tlog.Info(\"database migrations applied\")\n\n\t// --- JWT secret ---\n\tjwtSecret := cfg.JWTSecret\n\tif jwtSecret == \"\" {\n\t\tb := make([]byte, 32)\n\t\tif _, err := rand.Read(b); err != nil {\n\t\t\tlog.Fatalf(\"failed to generate jwt_secret: %v\", err)\n\t\t}\n\t\tjwtSecret = hex.EncodeToString(b)\n\t\tlog.Warn(\"jwt_secret not set in config — using auto-generated ephemeral secret (tokens will not survive restarts)\")\n\t}\n\n\t// Set encryption key: env var > config file > jwt_secret-derived fallback.\n\tencKeyHex := os.Getenv(\"ADMONT_ENCRYPTION_KEY\")\n\tif encKeyHex == \"\" {\n\t\tencKeyHex = cfg.EncryptionKey\n\t}\n\tif encKeyHex != \"\" {\n\t\tencKey, err := hex.DecodeString(encKeyHex)\n\t\tif err != nil || len(encKey) != 32 {\n\t\t\tlog.Fatal(\"encryption_key must be a hex-encoded 32-byte (64 char) key\")\n\t\t}\n\t\tdb.SetEncryptionKeyRaw(encKey)\n\t\tlog.Info(\"encryption key loaded from configuration\")\n\t} else {\n\t\tdb.SetEncryptionKey(jwtSecret)\n\t\tlog.Info(\"encryption key derived from jwt_secret\")\n\t}\n\tdb.InitSubStores()\n\n\t// Derive signing key for TOTP pending tokens.\n\tsigningKeyHash := sha256.Sum256([]byte(jwtSecret))\n\tsigningKey := signingKeyHash[:]\n\n\tbaseURL := cfg.AuthBaseURL\n\tif baseURL == \"\" {\n\t\t// Derive from service config, replacing 0.0.0.0 with localhost for valid callbacks.\n\t\thost := cfg.Hostname\n\t\tif host == \"\" || host == \"0.0.0.0\" {\n\t\t\thost = \"localhost\"\n\t\t}\n\t\tbaseURL = fmt.Sprintf(\"http://%s:%d\", host, cfg.Port)\n\t\tlog.WithField(\"base_url\", baseURL).Info(\"auth_base_url derived from service config\")\n\t}\n\n\t// --- Load auth providers from DB ---\n\thttpCallbackURL := baseURL + \"/auth/callback\"\n\tmcpCallbackURL := baseURL + \"/mcp/callback\"\n\n\thttpRegistry := auth.NewRegistry()\n\tmcpRegistry := auth.NewRegistry()\n\n\t// Register Hydra as the internal auth provider directly from service config.\n\tif cfg.InternalAuth.Enabled {\n\t\tif cfg.InternalAuth.PublicURL == \"\" || cfg.InternalAuth.AdminURL == \"\" {\n\t\t\tlog.Fatal(\"internal_auth.public_url and internal_auth.admin_url must be set when internal_auth.enabled is true\")\n\t\t}\n\n\t\tif err := setupHydra(ctx, db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry); err != nil {\n\t\t\t// Non-fatal: start without internal auth, retry in background.\n\t\t\tlog.WithError(err).Warn(\"Hydra not ready — internal auth disabled, retrying in background\")\n\t\t\tgo retryHydraSetup(db, cfg, httpCallbackURL, mcpCallbackURL, httpRegistry, mcpRegistry)\n\t\t}\n\t}\n\n\tdbAuthProviders, err := db.Auth.ListAuthProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load auth providers from database\")\n\t}\n\n\tallAuthProviders := make([]storeauth.AuthProvider, 0, len(dbAuthProviders))\n\tfor _, p := range dbAuthProviders {\n\t\thttpEntry, err := auth.NewProviderFromConfig(p, httpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create HTTP auth provider\")\n\t\t\tcontinue\n\t\t}\n\t\thttpRegistry.Register(httpEntry)\n\n\t\tmcpEntry, err := auth.NewProviderFromConfig(p, mcpCallbackURL)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"provider\", p.Name).Error(\"failed to create MCP auth provider\")\n\t\t\thttpRegistry.Unregister(p.Name)\n\t\t\tcontinue\n\t\t}\n\t\tmcpRegistry.Register(mcpEntry)\n\n\t\tallAuthProviders = append(allAuthProviders, p)\n\t\tlog.WithField(\"provider\", p.Name).Info(\"auth provider registered\")\n\t}\n\n\tif len(allAuthProviders) == 0 {\n\t\tlog.Warn(\"no auth providers configured — add via POST /admin/auth\")\n\t}\n\n\tjwtService := auth.NewJWTService(jwtSecret, 24*time.Hour)\n\tauthHandler := auth.NewHandler(httpRegistry, jwtService)\n\n\t// --- Load users and groups from DB ---\n\tusers, err := db.Users.ListAllUsers(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load users from database\")\n\t}\n\tlog.WithField(\"count\", len(users)).Info(\"users loaded from database\")\n\n\tgroups, err := db.Users.ListGroups(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load groups from database\")\n\t}\n\tlog.WithField(\"count\", len(groups)).Info(\"groups loaded from database\")\n\n\t// --- Load repos from DB ---\n\tdbRepos, err := db.Repos.ListRepos(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to load repos from database\")\n\t}\n\n\tvar repoReady sync.Map\n\tbackends := make(map[string]repo.RepoBackend)\n\tdocPaths := make(map[string]string)\n\trepoConfigs := make(map[string]*git_repo.GitRepo)\n\tdraftManagers := make(map[string]*draft.Manager)\n\tfor i := range dbRepos {\n\t\tr := &dbRepos[i]\n\t\tslug := r.Slug()\n\t\tvar repoPath string\n\t\tif r.BackendType == \"local_git\" {\n\t\t\trepoPath = filepath.Join(cfg.LocalRepoPath, slug)\n\t\t} else {\n\t\t\trepoPath = filepath.Join(cfg.RepoClonePath, slug)\n\t\t}\n\t\tbackend, err := repofactory.NewBackend(r, repoPath)\n\t\tif err != nil {\n\t\t\tlog.WithError(err).WithField(\"repo\", slug).Fatal(\"failed to create repo backend\")\n\t\t}\n\n\t\tbackends[slug] = backend\n\t\trepoConfigs[slug] = r\n\t\tif r.DocPath != \"\" {\n\t\t\tdocPaths[slug] = r.DocPath\n\t\t}\n\t\tvar dm *draft.Manager\n\t\tif s3b, ok := backend.(*s3backend.Backend); ok {\n\t\t\tdm = draft.NewManagerWithStore(draft.NewS3Store(s3b.S3Client(), s3b.Bucket(), s3b.Prefix()))\n\t\t} else {\n\t\t\tdm = draft.NewManager(backend.RepoPath())\n\t\t}\n\t\tdraftManagers[slug] = dm\n\n\t\trepoReady.Store(slug, false)\n\t}\n\n\t// --- Model registry ---\n\tmodelRegistry := llm.NewModelRegistry()\n\tmodelRegistry.RegisterFallback(\"anthropic\", llm.AnthropicModels, llm.AnthropicDefaultModel)\n\tmodelRegistry.RegisterFallback(\"deepseek\", llm.DeepSeekModels, llm.DeepSeekDefaultModel)\n\tmodelRegistry.RegisterFallback(\"google\", llm.GoogleModels, llm.GoogleDefaultModel)\n\tmodelRegistry.RegisterFallback(\"meta\", llm.MetaModels, llm.MetaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"mistral\", llm.MistralModels, llm.MistralDefaultModel)\n\tmodelRegistry.RegisterFallback(\"ollama\", llm.OllamaModels, llm.OllamaDefaultModel)\n\tmodelRegistry.RegisterFallback(\"openai\", llm.OpenAIModels, llm.OpenAIDefaultModel)\n\tmodelRegistry.RegisterFallback(\"perplexity\", llm.PerplexityModels, llm.PerplexityDefaultModel)\n\tmodelRegistry.RegisterFallback(\"xai\", llm.XAIModels, llm.XAIDefaultModel)\n\n\t// --- LLM client from DB ---\n\tllmClient := buildLLMClient(db, modelRegistry)\n\n\t// Fetch models dynamically at startup (with timeout)\n\tfetchCtx, fetchCancel := context.WithTimeout(ctx, 60*time.Second)\n\tmodelRegistry.FetchAll(fetchCtx)\n\tfetchCancel()\n\n\t// Refresh models periodically\n\tmodelRegistry.StartRefresh(1 * time.Hour)\n\tdefer modelRegistry.Stop()\n\n\tllmHandler := requesthandler.NewLLMRequesthandler(llmClient)\n\n\t// Grammar & spelling checker (LanguageTool)\n\tvar chk checker.Checker\n\tif cfg.LanguageToolURL != \"\" {\n\t\tchk = checker.NewLanguageToolChecker(cfg.LanguageToolURL)\n\t\tlog.WithField(\"url\", cfg.LanguageToolURL).Info(\"LanguageTool checker configured\")\n\t}\n\tcheckerHandler := requesthandler.NewCheckerRequesthandler(chk)\n\n\trepoHandler := requesthandler.NewRepoRequesthandler(backends, draftManagers, docPaths, repoConfigs, &repoReady)\n\n\t// Launch repo initialization goroutines (after repoHandler is created so we can set resolvers)\n\tfor slug, backend := range backends {\n\t\tdm := draftManagers[slug]\n\t\trc := repoConfigs[slug]\n\t\tgo func(slug string, rc *git_repo.GitRepo, backend repo.RepoBackend, dm *draft.Manager) {\n\t\t\t// For git-based backends, try to sync an existing clone before falling back to full init.\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tgitDir := filepath.Join(backend.RepoPath(), \".git\")\n\t\t\t\tif info, err := os.Stat(gitDir); err == nil && info.IsDir() {\n\t\t\t\t\tlog.WithField(\"repo\", rc.RepoUrl).Info(\"repository exists, pulling changes\")\n\t\t\t\t\tif err := backend.Sync(); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Warn(\"sync failed, re-initializing\")\n\t\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\t\tlog.WithError(err).WithField(\"repo\", rc.RepoUrl).Error(\"failed to initialize repository\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := backend.Initialize(context.Background()); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Error(\"failed to initialize repository\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif backend.Type() == \"remote_git\" || backend.Type() == \"local_git\" || backend.Type() == \"s3_git\" {\n\t\t\t\tif err := dm.EnsureGitignore(); err != nil {\n\t\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to ensure .drafts in .gitignore\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Load file-level permissions\n\t\t\tresolver, err := loadPermissions(backend)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithError(err).WithField(\"repo\", slug).Warn(\"failed to load permissions file\")\n\t\t\t} else if resolver != nil {\n\t\t\t\trepoHandler.SetPermissionResolver(slug, resolver)\n\t\t\t\tlog.WithField(\"repo\", slug).Info(\"file permissions loaded\")\n\t\t\t}\n\n\t\t\trepoReady.Store(slug, true)\n\t\t\tlog.WithField(\"repo\", rc.RepoUrl).WithField(\"path\", backend.RepoPath()).Info(\"repository ready\")\n\t\t}(slug, rc, backend, dm)\n\t}\n\n\t// Search feature — always create holder/indexer so providers can be added at runtime.\n\trepoStateStore := storesearch.NewRepoStateStore(db.Search)\n\tbackendHolder := backend.NewHolder(nil)\n\tsearchIndexer := indexer.New(backendHolder, repoStateStore, backends, docPaths)\n\trepoHandler.SetIndexer(searchIndexer)\n\tsearchHandler := requesthandler.NewSearchRequesthandler(backendHolder, repoStateStore, backends, repoConfigs)\n\tragHandler := requesthandler.NewRAGRequesthandler(llmClient, backendHolder, backends, repoConfigs)\n\n\tsummarizer := llm.NewSummarizer(llmClient, db.Conversations)\n\tragHandler.SetConversationStore(db.Conversations, summarizer)\n\tconvHandler := requesthandler.NewConversationRequesthandler(db.Conversations)\n\n\t// Initialize search backend from existing provider (if any)\n\tsearchProviders, err := db.Search.ListSearchProviders(ctx)\n\tif err != nil {\n\t\tlog.WithError(err).Fatal(\"failed to list search providers\")\n\t}\n\n\tif len(searchProviders) > 0 {\n\t\tactiveProvider := searchProviders[0]\n\t\tlog.WithField(\"provider_type\", activeProvider.ProviderType).Info(\"initializing search backend\")\n\t\tif sb, err := initSearchBackend(activeProvider.ProviderType, activeProvider.Config, dsn, cfg.Search); err != nil {\n\t\t\tlog.WithError(err).Warn(\"failed to initialize search backend at startup — search will be unavailable until backend can be created\")\n\t\t} else {\n\t\t\tbackendHolder.Swap(sb)\n\t\t\t// sb will be closed via backendHolder.Swap or program exit\n\n\t\t\t// Trigger incremental reindex for repos with a search provider assigned\n\t\t\tfor slug, rc := range repoConfigs {\n\t\t\t\tif rc.SearchProviderID != nil {\n\t\t\t\t\tlog.WithField(\"repo\", slug).Info(\"triggering incremental search reindex\")\n\t\t\t\t\tsearchIndexer.IncrementalReindex(slug)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Info(\"search feature enabled\")\n\t\t}\n\t} else {\n\t\tlog.Info(\"no search providers configured — search feature disabled\")\n\t}\n\n\t// Factory for lazy search backend initialization from admin API\n\tsearchBackendFactory := func(providerType string, providerConfig map[string]string) (backend.SearchBackend, error) {\n\t\treturn initSearchBackend(providerType, providerConfig, dsn, cfg.Search)\n\t}\n\n\tengine := fuego.NewEngine()\n\n\t// Route Gin logs through logrus\n\tgin.DefaultWriter = log.StandardLogger().WriterLevel(log.InfoLevel)\n\tgin.DefaultErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)\n\tif cfg.ReleaseMode {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\t// Suppress Gin's built-in route debug printing; we log routes ourselves below.\n\tgin.DebugPrintRouteFunc = func(string, string, string, int) {}\n\n\tr := gin.New()\n\tr.Use(gin.RecoveryWithWriter(log.StandardLogger().WriterLevel(log.ErrorLevel)))\n\tr.Use(ginLogrus())\n\n\tr.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{\"/mcp/\"})))\n\tr.Use(cors.New(cors.Config{\n\t\tAllowOrigins: cfg.AllowedOrigins,\n\t\tAllowMethods: []string{\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"},\n\t\tAllowHeaders: []string{\"Origin\", \"Content-Type\", \"Authorization\"},\n\t\tAllowCredentials: true,\n\t}))\n\n\tfuegogin.Get(engine, r, \"/health\", func(c fuego.ContextNoBody) (healthResponse, error) {\n\t\treturn healthResponse{Status: \"ok\"}, nil\n\t}, fuego.OptionTags(\"System\"))\n\n\tauthGroup := r.Group(\"/auth\")\n\tauthGroup.GET(\"/login\", authHandler.Login)\n\tauthGroup.GET(\"/callback\", authHandler.Callback)\n\tauthGroup.GET(\"/providers\", authHandler.Providers)\n\n\t// Hydra login/consent flow (only when Hydra is enabled)\n\tif cfg.InternalAuth.Enabled {\n\t\thydraLoginHandler := auth.NewHydraLoginHandler(cfg.InternalAuth.AdminURL, db, cfg.InternalAuth.MaxFailedLogin, cfg.InternalAuth.FailedLoginIntervalMin, signingKey)\n\t\thydraGroup := r.Group(\"/hydra\")\n\t\thydraGroup.GET(\"/login\", hydraLoginHandler.LoginGet)\n\t\thydraGroup.POST(\"/login\", hydraLoginHandler.LoginPost)\n\t\thydraGroup.GET(\"/consent\", hydraLoginHandler.Consent)\n\t\thydraGroup.GET(\"/logout\", func(c *gin.Context) {\n\t\t\tc.Redirect(http.StatusFound, \"/\")\n\t\t})\n\t}\n\n\t// Create admin handler for runtime config management\n\tadminHandler := requesthandler.NewAdminRequesthandler(\n\t\tdb, backends, draftManagers, docPaths,\n\t\tusers, groups,\n\t\tcfg.RepoClonePath, cfg.LocalRepoPath, repoConfigs, &repoReady,\n\t\tallAuthProviders,\n\t\tbaseURL,\n\t\thttpRegistry, mcpRegistry,\n\t\tmodelRegistry,\n\t)\n\tadminHandler.SetIndexer(searchIndexer)\n\tadminHandler.SetSearchBackend(backendHolder, repoStateStore)\n\tadminHandler.SetSearchBackendFactory(searchBackendFactory)\n\tadminHandler.SetLLMRebuild(func() {\n\t\tnewClient := buildLLMClient(db, modelRegistry)\n\t\tllmHandler.SetClient(newClient)\n\t\tragHandler.SetClient(newClient)\n\t\tsummarizer.SetClient(newClient)\n\t\tfetchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tmodelRegistry.FetchAll(fetchCtx)\n\t\tcancel()\n\t\tlog.Info(\"LLM providers reloaded\")\n\t})\n\n\t// Wire up system admin check for file permissions\n\trepoHandler.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\n\t// Set default import path for Confluence imports\n\trepoHandler.SetImportPath(cfg.ImportPath)\n\n\t// Wire up search provider name resolver\n\trepoHandler.SetSearchProviderNameResolver(func(id int) string {\n\t\tname, err := db.Search.GetSearchProviderNameByID(context.Background(), id)\n\t\tif err != nil {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn name\n\t})\n\n\t// MCP server (SSE transport with multi-provider OAuth)\n\tmcpServer := wikimcp.NewServer(\n\t\tbackends, draftManagers, docPaths, repoConfigs, &repoReady,\n\t\trepoHandler.PermResolvers(),\n\t\tjwtService,\n\t\tmcpRegistry,\n\t\tbaseURL,\n\t)\n\tmcpServer.SetSystemAdminCheck(adminHandler.CanManageRepos)\n\tmcpServer.SetIndexer(searchIndexer)\n\tmcpServer.SetSearch(backendHolder, repoStateStore)\n\tmcpServer.RegisterRoutes(r)\n\tlog.Info(\"MCP server enabled at /mcp/sse\")\n\n\tmeGroup := r.Group(\"/me\")\n\tmeGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, meGroup, \"/details\", func(c fuego.ContextNoBody) (meDetailsResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tname, _ := gc.Get(middleware.CtxUserName)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\troles := adminHandler.GetUserRoles(userIdentity)\n\t\tif roles == nil {\n\t\t\troles = []string{}\n\t\t}\n\t\tresp := meDetailsResponse{\n\t\t\tEmail: email.(string),\n\t\t\tName: name.(string),\n\t\t\tProvider: provider.(string),\n\t\t\tRoles: roles,\n\t\t}\n\t\tif provider.(string) == \"internal\" {\n\t\t\tif user, err := db.Users.GetInternalUser(gc.Request.Context(), email.(string)); err == nil && user != nil {\n\t\t\t\tresp.PasswordExpired = user.PasswordExpired\n\t\t\t}\n\t\t}\n\t\treturn resp, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get current user details\"),\n\t)\n\n\ttype changePasswordRequest struct {\n\t\tCurrentPassword string `json:\"current_password\" validate:\"required\"`\n\t\tNewPassword string `json:\"new_password\" validate:\"required\"`\n\t}\n\ttype changePasswordResponse struct {\n\t\tMessage string `json:\"message\"`\n\t}\n\tfuegogin.Put(engine, meGroup, \"/password\", func(c fuego.ContextWithBody[changePasswordRequest]) (changePasswordResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"password change is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"invalid request body\"}\n\t\t}\n\t\tif body.CurrentPassword == \"\" || body.NewPassword == \"\" {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"current_password and new_password are required\"}\n\t\t}\n\t\tif len(body.NewPassword) < 8 {\n\t\t\treturn changePasswordResponse{}, fuego.BadRequestError{Detail: \"new password must be at least 8 characters\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\tstoredHash, err := db.Users.GetPasswordHash(gc.Request.Context(), userEmail)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.CurrentPassword)); err != nil {\n\t\t\treturn changePasswordResponse{}, fuego.ForbiddenError{Detail: \"current password is incorrect\"}\n\t\t}\n\t\thash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)\n\t\tif err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tctx := gc.Request.Context()\n\t\tif err := db.Users.SetPasswordHash(ctx, userEmail, string(hash)); err != nil {\n\t\t\treturn changePasswordResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\t_ = db.Users.ClearPasswordExpired(ctx, userEmail)\n\t\treturn changePasswordResponse{Message: \"password changed successfully\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Change password for internal user\"),\n\t)\n\n\t// --- TOTP self-service endpoints ---\n\ttype totpStatusResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t}\n\tfuegogin.Get(engine, meGroup, \"/totp/status\", func(c fuego.ContextNoBody) (totpStatusResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpStatusResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tenabled, err := db.Users.IsTOTPEnabled(gc.Request.Context(), email.(string))\n\t\tif err != nil {\n\t\t\treturn totpStatusResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\treturn totpStatusResponse{Enabled: enabled}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Get TOTP status for current user\"),\n\t)\n\n\ttype totpSetupResponse struct {\n\t\tSecret string `json:\"secret\"`\n\t\tProvisioningURI string `json:\"provisioning_uri\"`\n\t\tQRCode string `json:\"qr_code\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/setup\", func(c fuego.ContextNoBody) (totpSetupResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpSetupResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Generate a new TOTP key.\n\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\tIssuer: \"Admont\",\n\t\t\tAccountName: userEmail,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating TOTP key: %w\", err)\n\t\t}\n\n\t\t// Encrypt and store the secret (not yet enabled).\n\t\tencrypted, err := db.Encrypt(key.Secret())\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encrypting TOTP secret: %w\", err)\n\t\t}\n\t\tif err := db.Users.SetTOTPSecret(reqCtx, userEmail, encrypted); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"storing TOTP secret: %w\", err)\n\t\t}\n\n\t\t// Generate QR code as base64 PNG.\n\t\timg, err := key.Image(200, 200)\n\t\tif err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"generating QR code: %w\", err)\n\t\t}\n\t\tvar buf bytes.Buffer\n\t\tif err := png.Encode(&buf, img); err != nil {\n\t\t\treturn totpSetupResponse{}, fmt.Errorf(\"encoding QR code: %w\", err)\n\t\t}\n\t\tqrBase64 := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\t\treturn totpSetupResponse{\n\t\t\tSecret: key.Secret(),\n\t\t\tProvisioningURI: key.URL(),\n\t\t\tQRCode: \"data:image/png;base64,\" + qrBase64,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Generate TOTP secret and QR code\"),\n\t)\n\n\ttype totpVerifyRequest struct {\n\t\tCode string `json:\"code\" validate:\"required\"`\n\t}\n\ttype totpVerifyResponse struct {\n\t\tEnabled bool `json:\"enabled\"`\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/verify\", func(c fuego.ContextWithBody[totpVerifyRequest]) (totpVerifyResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Code == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"code is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\t// Get the stored (encrypted) secret.\n\t\tencryptedSecret, enabled, err := db.Users.GetTOTPSecret(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif enabled {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"TOTP is already enabled\"}\n\t\t}\n\t\tif encryptedSecret == \"\" {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"call /me/totp/setup first\"}\n\t\t}\n\n\t\tsecret, err := db.Decrypt(encryptedSecret)\n\t\tif err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\tif !totp.Validate(body.Code, secret) {\n\t\t\treturn totpVerifyResponse{}, fuego.BadRequestError{Detail: \"invalid TOTP code\"}\n\t\t}\n\n\t\t// Enable TOTP.\n\t\tif err := db.Users.EnableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\t// Generate recovery codes.\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpVerifyResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpVerifyResponse{\n\t\t\tEnabled: true,\n\t\t\tRecoveryCodes: plaintextCodes,\n\t\t}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Verify TOTP code and enable 2FA\"),\n\t)\n\n\ttype totpDisableRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\tfuegogin.Delete(engine, meGroup, \"/totp\", func(c fuego.ContextWithBody[totpDisableRequest]) (messageResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn messageResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn messageResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tif err := db.Users.DisableTOTP(reqCtx, userEmail); err != nil {\n\t\t\treturn messageResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn messageResponse{Message: \"TOTP disabled\"}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Disable TOTP (requires password)\"),\n\t)\n\n\ttype totpRegenerateRequest struct {\n\t\tPassword string `json:\"password\" validate:\"required\"`\n\t}\n\ttype totpRegenerateResponse struct {\n\t\tRecoveryCodes []string `json:\"recovery_codes\"`\n\t}\n\tfuegogin.Post(engine, meGroup, \"/totp/recovery-codes\", func(c fuego.ContextWithBody[totpRegenerateRequest]) (totpRegenerateResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tprovider, _ := gc.Get(middleware.CtxUserProvider)\n\t\tif provider.(string) != \"internal\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is only available for internal users\"}\n\t\t}\n\t\tbody, err := c.Body()\n\t\tif err != nil || body.Password == \"\" {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"password is required\"}\n\t\t}\n\t\temail, _ := gc.Get(middleware.CtxUserEmail)\n\t\tuserEmail := email.(string)\n\t\treqCtx := gc.Request.Context()\n\n\t\tenabled, err := db.Users.IsTOTPEnabled(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif !enabled {\n\t\t\treturn totpRegenerateResponse{}, fuego.BadRequestError{Detail: \"TOTP is not enabled\"}\n\t\t}\n\n\t\tstoredHash, err := db.Users.GetPasswordHash(reqCtx, userEmail)\n\t\tif err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\t\tif err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(body.Password)); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fuego.ForbiddenError{Detail: \"incorrect password\"}\n\t\t}\n\n\t\tplaintextCodes := make([]string, 10)\n\t\thashedCodes := make([]string, 10)\n\t\tfor i := range plaintextCodes {\n\t\t\tb := make([]byte, 5)\n\t\t\tif _, err := rand.Read(b); err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"generating recovery codes: %w\", err)\n\t\t\t}\n\t\t\tplaintextCodes[i] = hex.EncodeToString(b)\n\t\t\thash, err := bcrypt.GenerateFromPassword([]byte(plaintextCodes[i]), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"hashing recovery code: %w\", err)\n\t\t\t}\n\t\t\thashedCodes[i] = string(hash)\n\t\t}\n\t\tif err := db.Users.SetTOTPRecoveryCodes(reqCtx, userEmail, hashedCodes); err != nil {\n\t\t\treturn totpRegenerateResponse{}, fmt.Errorf(\"internal error\")\n\t\t}\n\n\t\treturn totpRegenerateResponse{RecoveryCodes: plaintextCodes}, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"Regenerate TOTP recovery codes (requires password)\"),\n\t)\n\n\tfuegogin.Get(engine, meGroup, \"/mcp-clients\", func(c fuego.ContextNoBody) ([]mcpClientResponse, error) {\n\t\tgc := c.Context().(*gin.Context)\n\t\tidentity, _ := gc.Get(middleware.CtxUserIdentity)\n\t\tuserIdentity, _ := identity.(string)\n\t\tsessions := mcpServer.ConnectedClients(userIdentity)\n\t\tresult := make([]mcpClientResponse, len(sessions))\n\t\tfor i, sess := range sessions {\n\t\t\tresult[i] = mcpClientResponse{\n\t\t\t\tID: sess.ID,\n\t\t\t\tName: sess.Name,\n\t\t\t\tConnectedAt: sess.ConnectedAt.UTC().Format(\"2006-01-02T15:04:05Z\"),\n\t\t\t}\n\t\t}\n\t\treturn result, nil\n\t},\n\t\tfuego.OptionTags(\"User\"),\n\t\tfuego.OptionSummary(\"List connected MCP clients for the current user\"),\n\t)\n\n\tllmGroup := r.Group(\"/llm\")\n\tllmGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Get(engine, llmGroup, \"/models\", llmHandler.GetModels,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"List available LLM models\"),\n\t)\n\tfuegogin.Post(engine, llmGroup, \"\", llmHandler.HandleLLM,\n\t\tfuego.OptionTags(\"LLM\"),\n\t\tfuego.OptionSummary(\"LLM-powered document actions\"),\n\t)\n\n\tconvGroup := r.Group(\"/conversations\")\n\tconvGroup.Use(middleware.JWTAuth(jwtService))\n\tfuegogin.Get(engine, convGroup, \"\", convHandler.List,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"List AI conversations\"),\n\t)\n\tfuegogin.Post(engine, convGroup, \"\", convHandler.Create,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Create a new AI conversation\"),\n\t)\n\tfuegogin.Get(engine, convGroup, \"/:id\", convHandler.Get,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Get conversation with messages\"),\n\t)\n\tfuegogin.Patch(engine, convGroup, \"/:id\", convHandler.Update,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Update conversation title\"),\n\t)\n\tfuegogin.Delete(engine, convGroup, \"/:id\", convHandler.Delete,\n\t\tfuego.OptionTags(\"Conversations\"),\n\t\tfuego.OptionSummary(\"Delete a conversation\"),\n\t)\n\n\tcheckerGroup := r.Group(\"/checker\")\n\tcheckerGroup.Use(middleware.JWTAuthOptional(jwtService))\n\tfuegogin.Post(engine, checkerGroup, \"\", checkerHandler.Check,\n\t\tfuego.OptionTags(\"Checker\"),\n\t\tfuego.OptionSummary(\"Check text for grammar and spelling errors\"),\n\t)\n\n\trepos := r.Group(\"/repos\")\n\trepos.Use(middleware.JWTAuthOptional(jwtService))\n\n\tfuegogin.Get(engine, repos, \"\", repoHandler.GetRepos,\n\t\tfuego.OptionTags(\"Repos\"),\n\t\tfuego.OptionSummary(\"List repositories\"),\n\t)\n\n\trepo := repos.Group(\"/:repo\")\n\trepo.Use(repoHandler.RepoAccessCheck())\n\n\tfuegogin.Get(engine, repo, \"\", repoHandler.ListFiles,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"List all files and folders\"),\n\t)\n\trepo.GET(\"/file/*path\", repoHandler.GetFile())\n\tfuegogin.Get(engine, repo, \"/fileinfo/*path\", repoHandler.GetFileInfo,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file info from Git\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filehistory/*path\", repoHandler.GetFileHistory,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file git history\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/filediff/*path\", repoHandler.GetFileDiff,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get diff between a commit and HEAD\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to diff against HEAD\", fuego.ParamRequired()),\n\t)\n\tfuegogin.Get(engine, repo, \"/fileatcommit/*path\", repoHandler.GetFileAtCommit,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Get file content at a specific commit\"),\n\t\tfuego.OptionQuery(\"commit\", \"Commit hash to retrieve file content from\", fuego.ParamRequired()),\n\t)\n\n\trepoWrite := repo.Group(\"\", repoHandler.RepoWriteCheck())\n\n\tfuegogin.Post(engine, repoWrite, \"/file/*path\", repoHandler.CreateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Create a file\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/file/*path\", repoHandler.UpdateFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Update a file\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/file/*path\", repoHandler.DeleteFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Delete a file\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/file/*path\", repoHandler.MoveFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Move a file\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/rename/*path\", repoHandler.RenameFile,\n\t\tfuego.OptionTags(\"Files\"),\n\t\tfuego.OptionSummary(\"Rename a file\"),\n\t)\n\n\trepoWrite.POST(\"/upload/*path\", repoHandler.UploadFile())\n\trepoWrite.POST(\"/import/confluence\", repoHandler.ImportConfluence())\n\trepoWrite.POST(\"/delete\", repoHandler.BulkDelete())\n\n\t// Draft routes\n\tfuegogin.Put(engine, repoWrite, \"/draft/*path\", repoHandler.SaveDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Save or update a draft\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/draft/publish/*path\", repoHandler.PublishDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Publish a draft\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/draft/*path\", repoHandler.DiscardDraft,\n\t\tfuego.OptionTags(\"Drafts\"),\n\t\tfuego.OptionSummary(\"Discard a draft\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\tfuegogin.Post(engine, repoWrite, \"/folder/*path\", repoHandler.CreateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Create a folder\"),\n\t\tfuego.OptionDefaultStatusCode(201),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/folder/*path\", repoHandler.UpdateFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Rename a folder\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/folder/*path\", repoHandler.DeleteFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Delete a folder\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Patch(engine, repoWrite, \"/folder/*path\", repoHandler.MoveFolder,\n\t\tfuego.OptionTags(\"Folders\"),\n\t\tfuego.OptionSummary(\"Move a folder\"),\n\t)\n\n\t// Order routes\n\tfuegogin.Put(engine, repoWrite, \"/order/*path\", repoHandler.SetOrder,\n\t\tfuego.OptionTags(\"Order\"),\n\t\tfuego.OptionSummary(\"Set directory ordering\"),\n\t)\n\n\t// Permission management routes (require authentication)\n\tfuegogin.Post(engine, repoWrite, \"/permissions/init\", repoHandler.InitPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Initialize or reset permissions for a repository\"),\n\t)\n\tfuegogin.Get(engine, repo, \"/permissions/*path\", repoHandler.GetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Get permissions for a path\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/permissions/*path\", repoHandler.SetPermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Set permissions for a path\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/permissions/*path\", repoHandler.RemovePermissions,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Remove permissions for a path\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\tfuegogin.Get(engine, repo, \"/groups\", repoHandler.GetPermissionGroups,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"List permission groups\"),\n\t)\n\tfuegogin.Post(engine, repoWrite, \"/groups/:name\", repoHandler.AddPermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Create a permission group\"),\n\t)\n\tfuegogin.Put(engine, repoWrite, \"/groups/:name\", repoHandler.UpdatePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Update a permission group\"),\n\t)\n\tfuegogin.Delete(engine, repoWrite, \"/groups/:name\", repoHandler.DeletePermissionGroup,\n\t\tfuego.OptionTags(\"Permissions\"),\n\t\tfuego.OptionSummary(\"Delete a permission group\"),\n\t\tfuego.OptionDefaultStatusCode(204),\n\t)\n\n\t// ===== /admin routes =====\n\tadminBase := r.Group(\"/admin\")\n\tadminBase.Use(middleware.JWTAuth(jwtService))\n\n\t// User management routes — user_admin role\n\tadminUsersGroup := adminBase.Group(\"/users\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminUsersGroup, \"\", adminHandler.GetAllUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all users\"),\n\t)\n\n\t// Internal user CRUD\n\tadminInternalUsers := adminUsersGroup.Group(\"/internal\")\n\tfuegogin.Get(engine, adminInternalUsers, \"\", adminHandler.GetInternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List internal users\"),\n\t)\n\tfuegogin.Post(engine, adminInternalUsers, \"\", adminHandler.AddInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an internal user\"),\n\t)\n\tfuegogin.Put(engine, adminInternalUsers, \"/:email\", adminHandler.UpdateInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email\", adminHandler.DeleteInternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an internal user\"),\n\t)\n\tfuegogin.Delete(engine, adminInternalUsers, \"/:email/totp\", adminHandler.ResetInternalUserTOTP,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Reset TOTP for an internal user\"),\n\t)\n\n\t// External user CRUD\n\tadminExternalUsers := adminUsersGroup.Group(\"/external\")\n\tfuegogin.Get(engine, adminExternalUsers, \"\", adminHandler.GetExternalUsers,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List external users\"),\n\t)\n\tfuegogin.Post(engine, adminExternalUsers, \"\", adminHandler.AddExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Create an external user\"),\n\t)\n\tfuegogin.Put(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.UpdateExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an external user\"),\n\t)\n\tfuegogin.Delete(engine, adminExternalUsers, \"/:provider/:email\", adminHandler.DeleteExternalUser,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an external user\"),\n\t)\n\n\t// Group management routes — user_admin only\n\tadminGroupsGroup := adminBase.Group(\"/groups\", middleware.AdminCheckFunc(adminHandler.IsUserAdmin))\n\tfuegogin.Get(engine, adminGroupsGroup, \"\", adminHandler.GetGroups,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all user groups\"),\n\t)\n\tfuegogin.Post(engine, adminGroupsGroup, \"\", adminHandler.AddGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a user group\"),\n\t)\n\tfuegogin.Put(engine, adminGroupsGroup, \"/:name\", adminHandler.UpdateGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a user group\"),\n\t)\n\tfuegogin.Delete(engine, adminGroupsGroup, \"/:name\", adminHandler.RemoveGroup,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a user group\"),\n\t)\n\n\t// Auth provider management routes — system_admin only\n\tadminAuthGroup := adminBase.Group(\"/auth\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminAuthGroup, \"/supported\", adminHandler.GetSupportedAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported auth provider types\"),\n\t)\n\tfuegogin.Get(engine, adminAuthGroup, \"\", adminHandler.GetAuthProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all auth providers\"),\n\t)\n\tfuegogin.Post(engine, adminAuthGroup, \"\", adminHandler.AddAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an auth provider\"),\n\t)\n\tfuegogin.Put(engine, adminAuthGroup, \"/:name\", adminHandler.UpdateAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an auth provider\"),\n\t)\n\tfuegogin.Delete(engine, adminAuthGroup, \"/:name\", adminHandler.RemoveAuthProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an auth provider\"),\n\t)\n\n\t// LLM provider management routes — system_admin only\n\tadminLLMGroup := adminBase.Group(\"/llm\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminLLMGroup, \"/supported\", adminHandler.GetSupportedLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported LLM provider types\"),\n\t)\n\tfuegogin.Get(engine, adminLLMGroup, \"\", adminHandler.GetLLMProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all LLM providers\"),\n\t)\n\tfuegogin.Post(engine, adminLLMGroup, \"\", adminHandler.AddLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add an LLM provider\"),\n\t)\n\tfuegogin.Put(engine, adminLLMGroup, \"/:name\", adminHandler.UpdateLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update an LLM provider\"),\n\t)\n\tfuegogin.Delete(engine, adminLLMGroup, \"/:name\", adminHandler.RemoveLLMProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove an LLM provider\"),\n\t)\n\n\t// Repo management routes — repo_admin role\n\tadminReposGroup := adminBase.Group(\"/repos\", middleware.AdminCheckFunc(adminHandler.CanManageRepos))\n\tfuegogin.Get(engine, adminReposGroup, \"\", adminHandler.GetRepos,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all repository configs\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"\", adminHandler.AddRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a repository\"),\n\t)\n\tfuegogin.Put(engine, adminReposGroup, \"/:slug/settings\", adminHandler.UpdateRepoSettings,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update repository settings\"),\n\t)\n\tfuegogin.Delete(engine, adminReposGroup, \"/:slug\", adminHandler.RemoveRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reclone\", adminHandler.RecloneRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Re-clone a repository\"),\n\t)\n\tfuegogin.Post(engine, adminReposGroup, \"/:slug/reindex\", adminHandler.ReindexRepo,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Rebuild search index for a repository\"),\n\t)\n\n\t// Search provider management routes — system_admin only\n\tadminSearchGroup := adminBase.Group(\"/search\", middleware.AdminCheckFunc(adminHandler.IsSystemAdmin))\n\tfuegogin.Get(engine, adminSearchGroup, \"/supported\", adminHandler.GetSupportedSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List supported search provider types\"),\n\t)\n\tfuegogin.Get(engine, adminSearchGroup, \"/providers\", adminHandler.GetSearchProviders,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"List all search providers\"),\n\t)\n\tfuegogin.Post(engine, adminSearchGroup, \"/providers\", adminHandler.AddSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Add a search provider\"),\n\t)\n\tfuegogin.Put(engine, adminSearchGroup, \"/providers/:name\", adminHandler.UpdateSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Update a search provider\"),\n\t)\n\tfuegogin.Delete(engine, adminSearchGroup, \"/providers/:name\", adminHandler.RemoveSearchProvider,\n\t\tfuego.OptionTags(\"Admin\"),\n\t\tfuego.OptionSummary(\"Remove a search provider\"),\n\t)\n\t// Search routes (always registered; returns 503 when no backend is active)\n\tfuegogin.Post(engine, repos, \"/search\", searchHandler.Search,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search documents\"),\n\t)\n\tfuegogin.Get(engine, repos, \"/search/status\", searchHandler.Status,\n\t\tfuego.OptionTags(\"Search\"),\n\t\tfuego.OptionSummary(\"Search index status\"),\n\t)\n\tfuegogin.Post(engine, repos, \"/rag\", ragHandler.RAG,\n\t\tfuego.OptionTags(\"RAG\"),\n\t\tfuego.OptionSummary(\"Retrieval-augmented generation Q&A\"),\n\t)\n\n\tfuegogin.Get(engine, r, \"/swagger/openapi.json\", engine.SpecHandler(), fuego.OptionHide())\n\tuiHandler := gin.WrapH(fuego.DefaultOpenAPIHandler(\"/swagger/openapi.json\"))\n\tr.GET(\"/swagger\", uiHandler)\n\tr.GET(\"/swagger/index.html\", uiHandler)\n\n\t// Log registered routes sorted by path\n\ttype routeEntry struct {\n\t\tmethod, path string\n\t}\n\tvar routes []routeEntry\n\tmaxMethod := 0\n\tfor _, route := range r.Routes() {\n\t\tif len(route.Method) > maxMethod {\n\t\t\tmaxMethod = len(route.Method)\n\t\t}\n\t\troutes = append(routes, routeEntry{route.Method, route.Path})\n\t}\n\tsort.Slice(routes, func(i, j int) bool {\n\t\tif routes[i].path != routes[j].path {\n\t\t\treturn routes[i].path < routes[j].path\n\t\t}\n\t\treturn routes[i].method < routes[j].method\n\t})\n\tvar lines []string\n\tfor _, r := range routes {\n\t\tlines = append(lines, fmt.Sprintf(\" %-*s %s\", maxMethod, r.method, r.path))\n\t}\n\tlog.Infof(\"registered routes:\\n%s\", strings.Join(lines, \"\\n\"))\n\n\tr.Run(cfg.Addr())\n}\n\n// initSearchBackend creates a SearchBackend from provider type and config.\n// The caller is responsible for closing the returned backend.\nfunc initSearchBackend(providerType string, providerConfig map[string]string, fallbackDSN string, searchCfg config.SearchConfig) (backend.SearchBackend, error) {\n\tswitch providerType {\n\tcase \"pgvector\":\n\t\tsearchDSN := buildPgvectorDSN(providerConfig, fallbackDSN)\n\t\temb, err := embedder.New(searchCfg.OnnxRuntimePath, searchCfg.ModelPath, searchCfg.VocabPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"initializing embedder: %w\", err)\n\t\t}\n\t\tpgBackend, err := pgvectorbackend.New(context.Background(), searchDSN, emb)\n\t\tif err != nil {\n\t\t\temb.Close()\n\t\t\treturn nil, fmt.Errorf(\"creating pgvector backend: %w\", err)\n\t\t}\n\t\treturn pgBackend, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported search provider type: %s\", providerType)\n\t}\n}\n\n// buildLLMClient creates an LLM client from all providers stored in the database.\nfunc buildLLMClient(db *store.Store, registry *llm.ModelRegistry) *llm.Client {\n\tproviders, err := db.LLM.ListLLMProviders(context.Background())\n\tif err != nil {\n\t\tlog.WithError(err).Error(\"failed to load LLM providers from database\")\n\t\treturn llm.NewClient(registry)\n\t}\n\n\tclient := llm.NewClient(registry)\n\tfor _, cfg := range providers {\n\t\tif cfg.APIKey == \"\" && cfg.ProviderType != \"ollama\" {\n\t\t\tcontinue\n\t\t}\n\t\tregisterLLMProvider(client, registry, cfg)\n\t\tlog.WithFields(log.Fields{\"name\": cfg.Name, \"type\": cfg.ProviderType}).Info(\"LLM provider loaded\")\n\t}\n\treturn client\n}\n\n// registerLLMProvider creates and registers a provider + fetcher for the given config.\nfunc registerLLMProvider(client *llm.Client, registry *llm.ModelRegistry, cfg llm_provider.LLMConfig) {\n\tmaxTokens := cfg.MaxTokens\n\tif maxTokens == 0 {\n\t\tmaxTokens = 4096\n\t}\n\tvar provider llm.Provider\n\tprovType := cfg.ProviderType\n\tswitch provType {\n\tcase \"openai\":\n\t\tprovider = llm.NewOpenAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"\"))\n\tcase \"deepseek\":\n\t\tprovider = llm.NewDeepSeekProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.deepseek.com\"))\n\tcase \"google\":\n\t\tprovider = llm.NewGoogleProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewGoogleFetcher(cfg.APIKey))\n\tcase \"meta\":\n\t\tprovider = llm.NewMetaProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.llama.com/compat/v1\"))\n\tcase \"mistral\":\n\t\tprovider = llm.NewMistralProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.mistral.ai/v1\"))\n\tcase \"perplexity\":\n\t\tprovider = llm.NewPerplexityProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.perplexity.ai\"))\n\tcase \"xai\":\n\t\tprovider = llm.NewXAIProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(cfg.APIKey, \"https://api.x.ai/v1\"))\n\tcase \"ollama\":\n\t\tprovider = llm.NewOllamaProvider(cfg.BaseURL, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewOpenAICompatFetcher(\"ollama\", cfg.BaseURL))\n\tdefault:\n\t\tprovider = llm.NewAnthropicProvider(cfg.APIKey, maxTokens)\n\t\tregistry.RegisterFetcher(provType, llm.NewAnthropicFetcher(cfg.APIKey))\n\t}\n\tregistry.MarkConfigured(provType)\n\tclient.AddProvider(provType, provider)\n}\n\n// buildPgvectorDSN returns a PostgreSQL DSN for the search backend.\n// If external_db is true, builds from config fields; otherwise uses the service DSN.\nfunc buildPgvectorDSN(cfg map[string]string, fallbackDSN string) string {\n\tif cfg[\"external_db\"] != \"true\" {\n\t\treturn fallbackDSN\n\t}\n\tport := cfg[\"port\"]\n\tif port == \"\" {\n\t\tport = \"5432\"\n\t}\n\tsslmode := \"disable\"\n\tif cfg[\"ssl_enabled\"] == \"true\" {\n\t\tsslmode = \"require\"\n\t}\n\treturn fmt.Sprintf(\"postgres://%s:%s@%s:%s/%s?sslmode=%s\", cfg[\"username\"], cfg[\"password\"], cfg[\"host\"], port, cfg[\"database\"], sslmode)\n}\n"}}}}