From 493d915fa73fd87f46b61f8520ab8901e861d19d Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 16 Jan 2026 14:43:58 +0100 Subject: [PATCH] refactor(server): encapsulate service initialization in ServerBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all service creation (I18n, Email, MagicLink, Session, Config) from main.go into ServerBuilder.Build(), making main.go minimal and self-contained. - ServerBuilder now only requires WithDB() and WithTenantProvider() - AuthProvider and Authorizer have sensible CE defaults - Rename DynamicAuthProvider → auth.Provider for simplicity - Remove unused With* methods for internal services --- backend/cmd/community/main.go | 122 +-------- .../application/services/config_service.go | 1 - backend/pkg/web/auth/dynamic_provider.go | 70 +++-- backend/pkg/web/auth/simple_authorizer.go | 8 +- backend/pkg/web/server.go | 241 +++++++++--------- 5 files changed, 159 insertions(+), 283 deletions(-) diff --git a/backend/cmd/community/main.go b/backend/cmd/community/main.go index 5c89ff8..3131a83 100644 --- a/backend/cmd/community/main.go +++ b/backend/cmd/community/main.go @@ -8,20 +8,14 @@ import ( "net/http" "os" "os/signal" - "path/filepath" "syscall" "time" - "github.com/btouchard/ackify-ce/backend/internal/application/services" - "github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/database" - "github.com/btouchard/ackify-ce/backend/internal/infrastructure/email" - "github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n" "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" "github.com/btouchard/ackify-ce/backend/pkg/config" "github.com/btouchard/ackify-ce/backend/pkg/logger" "github.com/btouchard/ackify-ce/backend/pkg/web" - webauth "github.com/btouchard/ackify-ce/backend/pkg/web/auth" ) // Build-time variables set via ldflags @@ -59,78 +53,12 @@ func main() { log.Fatalf("failed to initialize tenant provider: %v", err) } - // Create repositories needed for auth - oauthSessionRepo := database.NewOAuthSessionRepository(db, tenantProvider) - configRepo := database.NewConfigRepository(db, tenantProvider) - magicLinkRepo := database.NewMagicLinkRepository(db) - - // Create ConfigService (needed for dynamic auth config) - encryptionKey := cfg.OAuth.CookieSecret - configService := services.NewConfigService(configRepo, cfg, encryptionKey) - - // Initialize config from DB or ENV - err = tenant.WithTenantContextFromProvider(ctx, db, tenantProvider, func(txCtx context.Context) error { - return configService.Initialize(txCtx) - }) - if err != nil { - logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err) - } - - // Create i18n service - i18nService, err := i18n.NewI18n(getLocalesDir()) - if err != nil { - log.Fatalf("Failed to initialize i18n: %v", err) - } - - // Create email renderer and sender if SMTP is configured - var emailSender email.Sender - var emailRenderer *email.Renderer - if cfg.Mail.Host != "" { - emailRenderer = email.NewRenderer(getTemplatesDir(), cfg.App.BaseURL, cfg.App.Organisation, - cfg.Mail.FromName, cfg.Mail.From, cfg.Mail.DefaultLocale, i18nService) - emailSender = email.NewSMTPSender(cfg.Mail, emailRenderer) - } - - // Create MagicLinkService - magicLinkService := services.NewMagicLinkService(services.MagicLinkServiceConfig{ - Repository: magicLinkRepo, - EmailSender: emailSender, - I18n: i18nService, - BaseURL: cfg.App.BaseURL, - AppName: cfg.App.Organisation, - RateLimitPerEmail: cfg.Auth.MagicLinkRateLimitEmail, - RateLimitPerIP: cfg.Auth.MagicLinkRateLimitIP, - }) - - // Create a SessionService (always needed for session management) - sessionService := auth.NewSessionService(auth.SessionServiceConfig{ - CookieSecret: cfg.OAuth.CookieSecret, - SecureCookies: cfg.App.SecureCookies, - SessionRepo: oauthSessionRepo, - }) - - // Create DynamicAuthProvider (unified auth for OIDC + MagicLink) - authProvider := webauth.NewDynamicAuthProvider(webauth.DynamicAuthProviderConfig{ - ConfigProvider: configService, - SessionService: sessionService, - MagicLinkService: magicLinkService, - BaseURL: cfg.App.BaseURL, - }) - - // Create authorizer - authorizer := webauth.NewSimpleAuthorizer(cfg.App.AdminEmails, cfg.App.OnlyAdminCanCreate) - // === Build Server === + // All services (I18n, Email, MagicLink, Config, Session) and + // default providers (DynamicAuthProvider, SimpleAuthorizer) are created internally. server, err := web.NewServerBuilder(cfg, frontend, Version). WithDB(db). WithTenantProvider(tenantProvider). - WithAuthProvider(authProvider). - WithAuthorizer(authorizer). - WithConfigService(configService). - WithI18nService(i18nService). - WithEmailSender(emailSender). - WithEmailRenderer(emailRenderer). - WithMagicLinkService(magicLinkService). Build(ctx) if err != nil { log.Fatalf("Failed to create server: %v", err) @@ -158,49 +86,3 @@ func main() { log.Println("Community Edition server exited") } - -func getTemplatesDir() string { - if envDir := os.Getenv("ACKIFY_TEMPLATES_DIR"); envDir != "" { - return envDir - } - - if execPath, err := os.Executable(); err == nil { - execDir := filepath.Dir(execPath) - defaultDir := filepath.Join(execDir, "templates") - if _, err := os.Stat(defaultDir); err == nil { - return defaultDir - } - } - - possiblePaths := []string{"templates", "./templates"} - for _, path := range possiblePaths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - return "templates" -} - -func getLocalesDir() string { - if envDir := os.Getenv("ACKIFY_LOCALES_DIR"); envDir != "" { - return envDir - } - - if execPath, err := os.Executable(); err == nil { - execDir := filepath.Dir(execPath) - defaultDir := filepath.Join(execDir, "locales") - if _, err := os.Stat(defaultDir); err == nil { - return defaultDir - } - } - - possiblePaths := []string{"locales", "./locales"} - for _, path := range possiblePaths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - return "locales" -} diff --git a/backend/internal/application/services/config_service.go b/backend/internal/application/services/config_service.go index 07994eb..c172e2e 100644 --- a/backend/internal/application/services/config_service.go +++ b/backend/internal/application/services/config_service.go @@ -34,7 +34,6 @@ var ( ErrInvalidCategory = errors.New("invalid configuration category") ) -// configRepository defines the interface for config storage type configRepository interface { GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error) GetAll(ctx context.Context) ([]*models.TenantConfig, error) diff --git a/backend/pkg/web/auth/dynamic_provider.go b/backend/pkg/web/auth/dynamic_provider.go index 6275538..c8ddff2 100644 --- a/backend/pkg/web/auth/dynamic_provider.go +++ b/backend/pkg/web/auth/dynamic_provider.go @@ -22,34 +22,32 @@ import ( "github.com/btouchard/ackify-ce/backend/pkg/types" ) -// ConfigProvider provides dynamic configuration for auth. -type ConfigProvider interface { +type configProvider interface { GetConfig() *models.MutableConfig } -// MagicLinkService defines magic link operations. -type MagicLinkService interface { +type magicLinkService interface { RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*models.MagicLinkToken, error) VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*models.MagicLinkToken, error) CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error) } -// DynamicAuthProviderConfig holds configuration for creating a DynamicAuthProvider. -type DynamicAuthProviderConfig struct { - ConfigProvider ConfigProvider +// ProviderConfig holds a configuration for creating a Provider. +type ProviderConfig struct { + ConfigProvider configProvider SessionService *infraAuth.SessionService - MagicLinkService MagicLinkService + MagicLinkService magicLinkService BaseURL string } -// DynamicAuthProvider implements providers.AuthProvider with dynamic config. -// It reads OIDC/MagicLink configuration from ConfigProvider on each call, +// Provider implements providers.AuthProvider with dynamic config. +// It reads OIDC/MagicLink configuration from configProvider on each call, // supporting hot-reload of authentication settings. -type DynamicAuthProvider struct { - configProvider ConfigProvider +type Provider struct { + configProvider configProvider sessionService *infraAuth.SessionService - magicLinkService MagicLinkService + magicLinkService magicLinkService baseURL string // Cache for oauth2.Config to avoid recreating on every request @@ -59,9 +57,9 @@ type DynamicAuthProvider struct { cachedOIDCCfg models.OIDCConfig } -// NewDynamicAuthProvider creates a new dynamic auth provider. -func NewDynamicAuthProvider(cfg DynamicAuthProviderConfig) *DynamicAuthProvider { - return &DynamicAuthProvider{ +// NewAuthProvider creates a new dynamic auth provider. +func NewAuthProvider(cfg ProviderConfig) *Provider { + return &Provider{ configProvider: cfg.ConfigProvider, sessionService: cfg.SessionService, magicLinkService: cfg.MagicLinkService, @@ -71,30 +69,30 @@ func NewDynamicAuthProvider(cfg DynamicAuthProviderConfig) *DynamicAuthProvider // === Session Management === -func (p *DynamicAuthProvider) GetCurrentUser(r *http.Request) (*types.User, error) { +func (p *Provider) GetCurrentUser(r *http.Request) (*types.User, error) { return p.sessionService.GetUser(r) } -func (p *DynamicAuthProvider) SetCurrentUser(w http.ResponseWriter, r *http.Request, user *types.User) error { +func (p *Provider) SetCurrentUser(w http.ResponseWriter, r *http.Request, user *types.User) error { return p.sessionService.SetUser(w, r, user) } -func (p *DynamicAuthProvider) Logout(w http.ResponseWriter, r *http.Request) { +func (p *Provider) Logout(w http.ResponseWriter, r *http.Request) { p.sessionService.Logout(w, r) } -func (p *DynamicAuthProvider) IsConfigured() bool { +func (p *Provider) IsConfigured() bool { return p.IsOIDCEnabled() || p.IsMagicLinkEnabled() } // === OIDC Authentication === -func (p *DynamicAuthProvider) IsOIDCEnabled() bool { +func (p *Provider) IsOIDCEnabled() bool { cfg := p.configProvider.GetConfig() return cfg.OIDC.Enabled && cfg.OIDC.ClientID != "" && cfg.OIDC.ClientSecret != "" } -func (p *DynamicAuthProvider) StartOIDC(w http.ResponseWriter, r *http.Request, nextURL string) string { +func (p *Provider) StartOIDC(w http.ResponseWriter, r *http.Request, nextURL string) string { if !p.IsOIDCEnabled() { logger.Logger.Error("StartOIDC called but OIDC is not enabled") return "" @@ -144,7 +142,7 @@ func (p *DynamicAuthProvider) StartOIDC(w http.ResponseWriter, r *http.Request, oauth2.SetAuthURLParam("code_challenge_method", "S256")) } -func (p *DynamicAuthProvider) startOIDCWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string, oauthConfig *oauth2.Config) string { +func (p *Provider) startOIDCWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string, oauthConfig *oauth2.Config) string { randPart := securecookie.GenerateRandomKey(20) token := base64.RawURLEncoding.EncodeToString(randPart) state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL)) @@ -165,7 +163,7 @@ func (p *DynamicAuthProvider) startOIDCWithoutPKCE(w http.ResponseWriter, r *htt return oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("prompt", promptParam)) } -func (p *DynamicAuthProvider) VerifyOIDCState(w http.ResponseWriter, r *http.Request, stateToken string) bool { +func (p *Provider) VerifyOIDCState(w http.ResponseWriter, r *http.Request, stateToken string) bool { session, _ := p.sessionService.GetSession(r) stored, _ := session.Values["oauth_state"].(string) @@ -182,7 +180,7 @@ func (p *DynamicAuthProvider) VerifyOIDCState(w http.ResponseWriter, r *http.Req return false } -func (p *DynamicAuthProvider) HandleOIDCCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*types.User, string, error) { +func (p *Provider) HandleOIDCCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*types.User, string, error) { if !p.IsOIDCEnabled() { return nil, "/", fmt.Errorf("OIDC is not enabled") } @@ -252,7 +250,7 @@ func (p *DynamicAuthProvider) HandleOIDCCallback(ctx context.Context, w http.Res return user, nextURL, nil } -func (p *DynamicAuthProvider) GetOIDCLogoutURL() string { +func (p *Provider) GetOIDCLogoutURL() string { cfg := p.configProvider.GetConfig() if cfg.OIDC.LogoutURL == "" { return "" @@ -260,7 +258,7 @@ func (p *DynamicAuthProvider) GetOIDCLogoutURL() string { return cfg.OIDC.LogoutURL + "?continue=" + p.baseURL } -func (p *DynamicAuthProvider) IsAllowedDomain(email string) bool { +func (p *Provider) IsAllowedDomain(email string) bool { cfg := p.configProvider.GetConfig() if cfg.OIDC.AllowedDomain == "" { return true @@ -276,12 +274,12 @@ func (p *DynamicAuthProvider) IsAllowedDomain(email string) bool { // === MagicLink Authentication === -func (p *DynamicAuthProvider) IsMagicLinkEnabled() bool { +func (p *Provider) IsMagicLinkEnabled() bool { cfg := p.configProvider.GetConfig() return cfg.MagicLink.Enabled && cfg.SMTP.Host != "" } -func (p *DynamicAuthProvider) RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error { +func (p *Provider) RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error { if !p.IsMagicLinkEnabled() { return fmt.Errorf("MagicLink is not enabled") } @@ -291,7 +289,7 @@ func (p *DynamicAuthProvider) RequestMagicLink(ctx context.Context, email, redir return p.magicLinkService.RequestMagicLink(ctx, email, redirectTo, ip, userAgent, locale) } -func (p *DynamicAuthProvider) VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) { +func (p *Provider) VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) { if p.magicLinkService == nil { return nil, fmt.Errorf("MagicLink service not configured") } @@ -308,7 +306,7 @@ func (p *DynamicAuthProvider) VerifyMagicLink(ctx context.Context, token, ip, us }, nil } -func (p *DynamicAuthProvider) VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) { +func (p *Provider) VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) { if p.magicLinkService == nil { return nil, fmt.Errorf("MagicLink service not configured") } @@ -325,7 +323,7 @@ func (p *DynamicAuthProvider) VerifyReminderAuthToken(ctx context.Context, token }, nil } -func (p *DynamicAuthProvider) CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error) { +func (p *Provider) CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error) { if p.magicLinkService == nil { return "", fmt.Errorf("MagicLink service not configured") } @@ -334,7 +332,7 @@ func (p *DynamicAuthProvider) CreateReminderAuthToken(ctx context.Context, email // === Internal helpers === -func (p *DynamicAuthProvider) getOAuthConfig() *oauth2.Config { +func (p *Provider) getOAuthConfig() *oauth2.Config { cfg := p.configProvider.GetConfig() p.mu.RLock() @@ -372,14 +370,14 @@ func (p *DynamicAuthProvider) getOAuthConfig() *oauth2.Config { return p.cachedOAuthCfg } -func (p *DynamicAuthProvider) configMatches(cfg models.OIDCConfig) bool { +func (p *Provider) configMatches(cfg models.OIDCConfig) bool { return p.cachedOIDCCfg.ClientID == cfg.ClientID && p.cachedOIDCCfg.ClientSecret == cfg.ClientSecret && p.cachedOIDCCfg.AuthURL == cfg.AuthURL && p.cachedOIDCCfg.TokenURL == cfg.TokenURL } -func (p *DynamicAuthProvider) parseUserInfo(resp *http.Response) (*types.User, error) { +func (p *Provider) parseUserInfo(resp *http.Response) (*types.User, error) { var rawUser map[string]interface{} body, err := io.ReadAll(resp.Body) if err != nil { @@ -436,4 +434,4 @@ func subtleConstantTimeCompare(a, b string) bool { } // Compile-time interface check -var _ providers.AuthProvider = (*DynamicAuthProvider)(nil) +var _ providers.AuthProvider = (*Provider)(nil) diff --git a/backend/pkg/web/auth/simple_authorizer.go b/backend/pkg/web/auth/simple_authorizer.go index 650183e..0bd1ed3 100644 --- a/backend/pkg/web/auth/simple_authorizer.go +++ b/backend/pkg/web/auth/simple_authorizer.go @@ -5,7 +5,7 @@ import ( "context" "strings" - "github.com/btouchard/ackify-ce/backend/pkg/web" + "github.com/btouchard/ackify-ce/backend/pkg/providers" ) // SimpleAuthorizer is an authorization implementation based on a list of admin emails. @@ -30,13 +30,13 @@ func NewSimpleAuthorizer(adminEmails []string, onlyAdminCanCreate bool) *SimpleA } } -// IsAdmin implements web.Authorizer. +// IsAdmin implements providers.Authorizer. func (a *SimpleAuthorizer) IsAdmin(_ context.Context, userEmail string) bool { normalized := strings.ToLower(strings.TrimSpace(userEmail)) return a.adminEmails[normalized] } -// CanCreateDocument implements web.Authorizer. +// CanCreateDocument implements providers.Authorizer. func (a *SimpleAuthorizer) CanCreateDocument(ctx context.Context, userEmail string) bool { if !a.onlyAdminCanCreate { return true @@ -45,4 +45,4 @@ func (a *SimpleAuthorizer) CanCreateDocument(ctx context.Context, userEmail stri } // Compile-time interface check. -var _ web.Authorizer = (*SimpleAuthorizer)(nil) +var _ providers.Authorizer = (*SimpleAuthorizer)(nil) diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index f05b1c6..051eeb7 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -28,6 +28,7 @@ import ( "github.com/btouchard/ackify-ce/backend/pkg/crypto" "github.com/btouchard/ackify-ce/backend/pkg/logger" "github.com/btouchard/ackify-ce/backend/pkg/storage" + webauth "github.com/btouchard/ackify-ce/backend/pkg/web/auth" sdk "github.com/btouchard/shm/sdk/golang" ) @@ -52,8 +53,10 @@ type Server struct { } // ServerBuilder allows dependency injection for extensibility. -// AuthProvider and Authorizer are REQUIRED and must be provided. -// QuotaEnforcer and AuditLogger have sensible defaults for CE. +// DB and TenantProvider are REQUIRED. +// AuthProvider and Authorizer have sensible CE defaults (AuthProvider, SimpleAuthorizer). +// QuotaEnforcer and AuditLogger have sensible CE defaults (NoLimit, LogOnly). +// All technical services (I18n, Email, MagicLink, Reminder, Config) are created internally. type ServerBuilder struct { cfg *config.Config frontend embed.FS @@ -62,21 +65,22 @@ type ServerBuilder struct { // Core infrastructure (required) db *sql.DB tenantProvider tenant.Provider - signer *crypto.Ed25519Signer - // Capability providers (auth and authorizer are REQUIRED) + // Capability providers (all have CE defaults) authProvider AuthProvider authorizer Authorizer quotaEnforcer QuotaEnforcer auditLogger AuditLogger - // Optional infrastructure + // Internal infrastructure (created by Build) + signer *crypto.Ed25519Signer i18nService *i18n.I18n emailSender email.Sender emailRenderer *email.Renderer storageProvider storage.Provider + sessionService *auth.SessionService - // Core services (created internally or injected) + // Internal services (created by Build) magicLinkService *services.MagicLinkService signatureService *services.SignatureService documentService *services.DocumentService @@ -95,44 +99,18 @@ func NewServerBuilder(cfg *config.Config, frontend embed.FS, version string) *Se } } -// WithDB injects a database connection. +// WithDB injects a database connection (REQUIRED). func (b *ServerBuilder) WithDB(db *sql.DB) *ServerBuilder { b.db = db return b } -// WithTenantProvider injects a tenant provider. +// WithTenantProvider injects a tenant provider (REQUIRED). func (b *ServerBuilder) WithTenantProvider(tp tenant.Provider) *ServerBuilder { b.tenantProvider = tp return b } -// WithSigner injects a cryptographic signer. -func (b *ServerBuilder) WithSigner(signer *crypto.Ed25519Signer) *ServerBuilder { - b.signer = signer - return b -} - -// WithI18nService injects an i18n service. -func (b *ServerBuilder) WithI18nService(i18n *i18n.I18n) *ServerBuilder { - b.i18nService = i18n - return b -} - -// WithEmailSender injects an email sender. -func (b *ServerBuilder) WithEmailSender(sender email.Sender) *ServerBuilder { - b.emailSender = sender - return b -} - -// WithEmailRenderer injects an email renderer. -func (b *ServerBuilder) WithEmailRenderer(renderer *email.Renderer) *ServerBuilder { - b.emailRenderer = renderer - return b -} - -// === Capability Providers === - // WithAuthProvider injects an authentication provider (REQUIRED). func (b *ServerBuilder) WithAuthProvider(provider AuthProvider) *ServerBuilder { b.authProvider = provider @@ -157,62 +135,31 @@ func (b *ServerBuilder) WithAuditLogger(logger AuditLogger) *ServerBuilder { return b } -// WithMagicLinkService injects a magic link service. -func (b *ServerBuilder) WithMagicLinkService(service *services.MagicLinkService) *ServerBuilder { - b.magicLinkService = service - return b -} - -// WithSignatureService injects a signature service. -func (b *ServerBuilder) WithSignatureService(service *services.SignatureService) *ServerBuilder { - b.signatureService = service - return b -} - -// WithDocumentService injects a document service. -func (b *ServerBuilder) WithDocumentService(service *services.DocumentService) *ServerBuilder { - b.documentService = service - return b -} - -// WithAdminService injects an admin service. -func (b *ServerBuilder) WithAdminService(service *services.AdminService) *ServerBuilder { - b.adminService = service - return b -} - -// WithWebhookService injects a webhook service. -func (b *ServerBuilder) WithWebhookService(service *services.WebhookService) *ServerBuilder { - b.webhookService = service - return b -} - -// WithReminderService injects a reminder service. -func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncService) *ServerBuilder { - b.reminderService = service - return b -} - -// WithConfigService injects a configuration service. -func (b *ServerBuilder) WithConfigService(service *services.ConfigService) *ServerBuilder { - b.configService = service - return b -} - // Build constructs the server with all dependencies. func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { if err := b.validateProviders(); err != nil { return nil, err } - b.setDefaultProviders() - if err := b.initializeInfrastructure(); err != nil { return nil, err } repos := b.createRepositories() + // Initialize services that depend on repos + if err := b.initializeConfigService(ctx, repos); err != nil { + return nil, err + } + b.initializeMagicLinkService(repos) + b.initializeSessionService(repos) + + // Now we can set default providers (they depend on services above) + b.setDefaultProviders() + + b.initializeCoreServices(repos) + b.initializeReminderService(repos) + if err := b.initializeTelemetry(ctx); err != nil { return nil, err } @@ -227,10 +174,7 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { return nil, err } - b.initializeCoreServices(repos) - b.initializeConfigService(ctx, repos) magicLinkWorker := b.initializeMagicLinkCleanupWorker(ctx) - b.initializeReminderService(repos) sessionWorker, err := b.initializeSessionWorker(ctx, repos) if err != nil { @@ -263,17 +207,29 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { // validateProviders checks that required providers are set. func (b *ServerBuilder) validateProviders() error { - if b.authProvider == nil { - return errors.New("authProvider is required: use WithAuthProvider()") + if b.db == nil { + return errors.New("database is required: use WithDB()") } - if b.authorizer == nil { - return errors.New("authorizer is required: use WithAuthorizer()") + if b.tenantProvider == nil { + return errors.New("tenantProvider is required: use WithTenantProvider()") } return nil } // setDefaultProviders sets default implementations for optional providers. +// Must be called AFTER initializeConfigService, initializeMagicLinkService, and initializeSessionService. func (b *ServerBuilder) setDefaultProviders() { + if b.authProvider == nil { + b.authProvider = webauth.NewAuthProvider(webauth.ProviderConfig{ + ConfigProvider: b.configService, + SessionService: b.sessionService, + MagicLinkService: b.magicLinkService, + BaseURL: b.cfg.App.BaseURL, + }) + } + if b.authorizer == nil { + b.authorizer = webauth.NewSimpleAuthorizer(b.cfg.App.AdminEmails, b.cfg.App.OnlyAdminCanCreate) + } if b.quotaEnforcer == nil { b.quotaEnforcer = NewNoLimitQuotaEnforcer() } @@ -282,19 +238,38 @@ func (b *ServerBuilder) setDefaultProviders() { } } -// initializeInfrastructure initializes signer and storage provider. -// i18n and emailSender are expected to be injected from main.go. +// initializeInfrastructure initializes signer, i18n, email and storage. func (b *ServerBuilder) initializeInfrastructure() error { var err error - if b.signer == nil { - b.signer, err = crypto.NewEd25519Signer() - if err != nil { - return fmt.Errorf("failed to initialize signer: %w", err) - } + // Signer + b.signer, err = crypto.NewEd25519Signer() + if err != nil { + return fmt.Errorf("failed to initialize signer: %w", err) } - if b.storageProvider == nil && b.cfg.Storage.IsEnabled() { + // I18n + b.i18nService, err = i18n.NewI18n(getLocalesDir()) + if err != nil { + return fmt.Errorf("failed to initialize i18n: %w", err) + } + + // Email (only if SMTP is configured) + if b.cfg.Mail.Host != "" { + b.emailRenderer = email.NewRenderer( + getTemplatesDir(), + b.cfg.App.BaseURL, + b.cfg.App.Organisation, + b.cfg.Mail.FromName, + b.cfg.Mail.From, + b.cfg.Mail.DefaultLocale, + b.i18nService, + ) + b.emailSender = email.NewSMTPSender(b.cfg.Mail, b.emailRenderer) + } + + // Storage + if b.cfg.Storage.IsEnabled() { provider, err := storage.NewProvider(b.cfg.Storage) if err != nil { return fmt.Errorf("failed to initialize storage provider: %w", err) @@ -319,6 +294,7 @@ type repositories struct { webhookDelivery *database.WebhookDeliveryRepository oauthSession *database.OAuthSessionRepository config *database.ConfigRepository + magicLink services.MagicLinkRepository } // createRepositories creates all repository instances. @@ -333,6 +309,7 @@ func (b *ServerBuilder) createRepositories() *repositories { webhookDelivery: database.NewWebhookDeliveryRepository(b.db, b.tenantProvider), oauthSession: database.NewOAuthSessionRepository(b.db, b.tenantProvider), config: database.NewConfigRepository(b.db, b.tenantProvider), + magicLink: database.NewMagicLinkRepository(b.db), } } @@ -395,29 +372,51 @@ func (b *ServerBuilder) initializeEmailWorker(ctx context.Context, repos *reposi // initializeCoreServices initializes signature, document, admin, and webhook services. func (b *ServerBuilder) initializeCoreServices(repos *repositories) { - if b.signatureService == nil { - b.signatureService = services.NewSignatureService(repos.signature, repos.document, b.signer) - b.signatureService.SetChecksumConfig(&b.cfg.Checksum) - } - if b.documentService == nil { - b.documentService = services.NewDocumentService(repos.document, repos.expectedSigner, &b.cfg.Checksum) - } - if b.adminService == nil { - b.adminService = services.NewAdminService(repos.document, repos.expectedSigner) - } - if b.webhookService == nil { - b.webhookService = services.NewWebhookService(repos.webhook, repos.webhookDelivery) - } + b.signatureService = services.NewSignatureService(repos.signature, repos.document, b.signer) + b.signatureService.SetChecksumConfig(&b.cfg.Checksum) + b.documentService = services.NewDocumentService(repos.document, repos.expectedSigner, &b.cfg.Checksum) + b.adminService = services.NewAdminService(repos.document, repos.expectedSigner) + b.webhookService = services.NewWebhookService(repos.webhook, repos.webhookDelivery) } -// initializeConfigService is a no-op when configService is injected from main.go. -// Kept for API compatibility. -func (b *ServerBuilder) initializeConfigService(_ context.Context, _ *repositories) { - // configService is expected to be injected and initialized from main.go +// initializeConfigService creates and initializes the configuration service. +func (b *ServerBuilder) initializeConfigService(ctx context.Context, repos *repositories) error { + encryptionKey := b.cfg.OAuth.CookieSecret + b.configService = services.NewConfigService(repos.config, b.cfg, encryptionKey) + + // Initialize config from DB or ENV + err := tenant.WithTenantContextFromProvider(ctx, b.db, b.tenantProvider, func(txCtx context.Context) error { + return b.configService.Initialize(txCtx) + }) + if err != nil { + logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err) + } + return nil +} + +// initializeMagicLinkService creates the magic link service. +func (b *ServerBuilder) initializeMagicLinkService(repos *repositories) { + b.magicLinkService = services.NewMagicLinkService(services.MagicLinkServiceConfig{ + Repository: repos.magicLink, + EmailSender: b.emailSender, + I18n: b.i18nService, + BaseURL: b.cfg.App.BaseURL, + AppName: b.cfg.App.Organisation, + RateLimitPerEmail: b.cfg.Auth.MagicLinkRateLimitEmail, + RateLimitPerIP: b.cfg.Auth.MagicLinkRateLimitIP, + }) +} + +// initializeSessionService creates the session service for auth. +func (b *ServerBuilder) initializeSessionService(repos *repositories) { + b.sessionService = auth.NewSessionService(auth.SessionServiceConfig{ + CookieSecret: b.cfg.OAuth.CookieSecret, + SecureCookies: b.cfg.App.SecureCookies, + SessionRepo: repos.oauthSession, + }) } // initializeMagicLinkCleanupWorker starts the cleanup worker for expired magic link tokens. -// magicLinkService is expected to be injected from main.go. func (b *ServerBuilder) initializeMagicLinkCleanupWorker(ctx context.Context) *workers.MagicLinkCleanupWorker { magicLinkWorker := workers.NewMagicLinkCleanupWorker(b.magicLinkService, 1*time.Hour, b.db, b.tenantProvider) go magicLinkWorker.Start(ctx) @@ -426,16 +425,14 @@ func (b *ServerBuilder) initializeMagicLinkCleanupWorker(ctx context.Context) *w // initializeReminderService initializes reminder service. func (b *ServerBuilder) initializeReminderService(repos *repositories) { - if b.reminderService == nil { - b.reminderService = services.NewReminderAsyncService( - repos.expectedSigner, - repos.reminder, - repos.emailQueue, - b.magicLinkService, - b.i18nService, - b.cfg.App.BaseURL, - ) - } + b.reminderService = services.NewReminderAsyncService( + repos.expectedSigner, + repos.reminder, + repos.emailQueue, + b.magicLinkService, + b.i18nService, + b.cfg.App.BaseURL, + ) } // initializeSessionWorker initializes OAuth session cleanup worker. @@ -465,7 +462,7 @@ func (b *ServerBuilder) buildRouter(repos *repositories, whPublisher *services.W DB: b.db, TenantProvider: b.tenantProvider, - // Capability providers (AuthProvider handles OIDC + MagicLink dynamically) + // Capability providers (Provider handles OIDC + MagicLink dynamically) AuthProvider: b.authProvider, Authorizer: b.authorizer, SignatureService: b.signatureService,