package app import ( "context" "errors" "fmt" "galaxy/authsession/internal/adapters/local" "galaxy/authsession/internal/adapters/mail" redisadapter "galaxy/authsession/internal/adapters/redis" "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" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) // 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()) } redisClient := redisadapter.NewClient(cfg.Redis) if err := redisadapter.InstrumentClient(redisClient, telemetryRuntime); err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: %w", err)) } runtime.cleanupFns = append(runtime.cleanupFns, func() error { err := redisClient.Close() if errors.Is(err, redis.ErrClosed) { return nil } return err }) if err := redisadapter.Ping(ctx, cfg.Redis, redisClient); err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: %w", err)) } challengeStore, err := challengestore.New(redisClient, challengestore.Config{ KeyPrefix: cfg.Redis.ChallengeKeyPrefix, OperationTimeout: cfg.Redis.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: challenge store: %w", err)) } sessionStore, err := sessionstore.New(redisClient, sessionstore.Config{ SessionKeyPrefix: cfg.Redis.SessionKeyPrefix, UserSessionsKeyPrefix: cfg.Redis.UserSessionsKeyPrefix, UserActiveSessionsKeyPrefix: cfg.Redis.UserActiveSessionsKeyPrefix, OperationTimeout: cfg.Redis.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: session store: %w", err)) } configStore, err := configprovider.New(redisClient, configprovider.Config{ SessionLimitKey: cfg.Redis.SessionLimitKey, OperationTimeout: cfg.Redis.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: config provider: %w", err)) } publisher, err := projectionpublisher.New(redisClient, projectionpublisher.Config{ SessionCacheKeyPrefix: cfg.Redis.GatewaySessionCacheKeyPrefix, SessionEventsStream: cfg.Redis.GatewaySessionEventsStream, StreamMaxLen: cfg.Redis.GatewaySessionEventsStreamMaxLen, OperationTimeout: cfg.Redis.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: projection publisher: %w", err)) } abuseProtector, err := sendemailcodeabuse.New(redisClient, sendemailcodeabuse.Config{ KeyPrefix: cfg.Redis.SendEmailCodeThrottleKeyPrefix, OperationTimeout: cfg.Redis.Conn.OperationTimeout, }) if err != nil { return cleanupOnError(fmt.Errorf("new authsession runtime: send email code abuse protector: %w", 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 }