package app import ( "context" "errors" "fmt" "galaxy/authsession/internal/adapters/local" "galaxy/authsession/internal/adapters/mail" "galaxy/authsession/internal/adapters/redis/challengestore" "galaxy/authsession/internal/adapters/redis/configprovider" "galaxy/authsession/internal/adapters/redis/projectionpublisher" "galaxy/authsession/internal/adapters/redis/sendemailcodeabuse" "galaxy/authsession/internal/adapters/redis/sessionstore" "galaxy/authsession/internal/adapters/userservice" "galaxy/authsession/internal/api/internalhttp" "galaxy/authsession/internal/api/publichttp" "galaxy/authsession/internal/config" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/blockuser" "galaxy/authsession/internal/service/confirmemailcode" "galaxy/authsession/internal/service/getsession" "galaxy/authsession/internal/service/listusersessions" "galaxy/authsession/internal/service/revokeallusersessions" "galaxy/authsession/internal/service/revokedevicesession" "galaxy/authsession/internal/service/sendemailcode" "galaxy/authsession/internal/telemetry" "go.uber.org/zap" ) type pinger interface { Ping(context.Context) error } type closer interface { Close() error } // Runtime owns the runnable authsession application plus the adapter cleanup // functions that must run after the process stops. type Runtime struct { // App coordinates the long-lived HTTP listeners. App *App cleanupFns []func() error } // NewRuntime constructs the runnable authsession process from cfg using the // Stage 18 Redis adapters, local runtime helpers, and the selectable mail and // user-service runtime adapters from Stages 20 and 21. func NewRuntime(ctx context.Context, cfg config.Config, logger *zap.Logger, telemetryRuntime *telemetry.Runtime) (*Runtime, error) { if ctx == nil { return nil, errors.New("new authsession runtime: nil context") } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new authsession runtime: %w", err) } if logger == nil { logger = zap.NewNop() } runtime := &Runtime{} cleanupOnError := func(err error) (*Runtime, error) { return nil, errors.Join(err, runtime.Close()) } challengeStore, err := challengestore.New(challengestore.Config{ Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB, TLSEnabled: cfg.Redis.TLSEnabled, KeyPrefix: cfg.Redis.ChallengeKeyPrefix, OperationTimeout: cfg.Redis.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: challenge store: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, challengeStore.Close) sessionStore, err := sessionstore.New(sessionstore.Config{ Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB, TLSEnabled: cfg.Redis.TLSEnabled, SessionKeyPrefix: cfg.Redis.SessionKeyPrefix, UserSessionsKeyPrefix: cfg.Redis.UserSessionsKeyPrefix, UserActiveSessionsKeyPrefix: cfg.Redis.UserActiveSessionsKeyPrefix, OperationTimeout: cfg.Redis.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: session store: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, sessionStore.Close) configStore, err := configprovider.New(configprovider.Config{ Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB, TLSEnabled: cfg.Redis.TLSEnabled, SessionLimitKey: cfg.Redis.SessionLimitKey, OperationTimeout: cfg.Redis.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: config provider: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, configStore.Close) publisher, err := projectionpublisher.New(projectionpublisher.Config{ Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB, TLSEnabled: cfg.Redis.TLSEnabled, SessionCacheKeyPrefix: cfg.Redis.GatewaySessionCacheKeyPrefix, SessionEventsStream: cfg.Redis.GatewaySessionEventsStream, StreamMaxLen: cfg.Redis.GatewaySessionEventsStreamMaxLen, OperationTimeout: cfg.Redis.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: projection publisher: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, publisher.Close) abuseProtector, err := sendemailcodeabuse.New(sendemailcodeabuse.Config{ Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB, TLSEnabled: cfg.Redis.TLSEnabled, KeyPrefix: cfg.Redis.SendEmailCodeThrottleKeyPrefix, OperationTimeout: cfg.Redis.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: send email code abuse protector: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, abuseProtector.Close) for name, dependency := range map[string]pinger{ "challenge store": challengeStore, "session store": sessionStore, "config provider": configStore, "projection publisher": publisher, "send email code abuse protector": abuseProtector, } { if err := dependency.Ping(ctx); err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: ping %s: %w", name, err)) } } clock := local.Clock{} idGenerator := local.IDGenerator{} codeGenerator := local.CodeGenerator{} codeHasher := local.CodeHasher{} var mailSender ports.MailSender switch cfg.MailService.Mode { case "stub": mailSender = &mail.StubSender{} case "rest": restClient, err := mail.NewRESTClient(mail.Config{ BaseURL: cfg.MailService.BaseURL, RequestTimeout: cfg.MailService.RequestTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: mail service REST client: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, restClient.Close) mailSender = restClient default: return cleanupOnError(fmt.Errorf("new authsession runtime: unsupported mail service mode %q", cfg.MailService.Mode)) } var userDirectory ports.UserDirectory switch cfg.UserService.Mode { case "stub": userDirectory = &userservice.StubDirectory{} case "rest": restClient, err := userservice.NewRESTClient(userservice.Config{ BaseURL: cfg.UserService.BaseURL, RequestTimeout: cfg.UserService.RequestTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: user service REST client: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, restClient.Close) userDirectory = restClient default: return cleanupOnError(fmt.Errorf("new authsession runtime: unsupported user service mode %q", cfg.UserService.Mode)) } sendEmailCodeService, err := sendemailcode.NewWithObservability( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, abuseProtector, clock, logger, telemetryRuntime, ) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: send email code service: %w", err)) } confirmEmailCodeService, err := confirmemailcode.NewWithObservability( challengeStore, sessionStore, userDirectory, configStore, publisher, idGenerator, codeHasher, clock, logger, telemetryRuntime, ) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: confirm email code service: %w", err)) } getSessionService, err := getsession.New(sessionStore) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: get session service: %w", err)) } listUserSessionsService, err := listusersessions.New(sessionStore) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: list user sessions service: %w", err)) } revokeDeviceSessionService, err := revokedevicesession.NewWithObservability(sessionStore, publisher, clock, logger, telemetryRuntime) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: revoke device session service: %w", err)) } revokeAllUserSessionsService, err := revokeallusersessions.NewWithObservability(sessionStore, userDirectory, publisher, clock, logger, telemetryRuntime) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: revoke all user sessions service: %w", err)) } blockUserService, err := blockuser.NewWithObservability(userDirectory, sessionStore, publisher, clock, logger, telemetryRuntime) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: block user service: %w", err)) } publicServer, err := publichttp.NewServer(cfg.PublicHTTP, publichttp.Dependencies{ SendEmailCode: sendEmailCodeService, ConfirmEmailCode: confirmEmailCodeService, Logger: logger, Telemetry: telemetryRuntime, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: public HTTP server: %w", err)) } internalServer, err := internalhttp.NewServer(cfg.InternalHTTP, internalhttp.Dependencies{ GetSession: getSessionService, ListUserSessions: listUserSessionsService, RevokeDeviceSession: revokeDeviceSessionService, RevokeAllUserSessions: revokeAllUserSessionsService, BlockUser: blockUserService, Logger: logger, Telemetry: telemetryRuntime, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: internal HTTP server: %w", err)) } runtime.App = New(cfg, publicServer, internalServer) return runtime, nil } // Close releases the runtime-managed adapter resources. Close is idempotent in // practice because every underlying adapter Close method is idempotent. func (r *Runtime) Close() error { if r == nil { return nil } var joined error for index := len(r.cleanupFns) - 1; index >= 0; index-- { joined = errors.Join(joined, r.cleanupFns[index]()) } return joined }