// Package authdirectory implements the auth-facing user-resolution, ensure, // existence, and block use cases owned by the user service. package authdirectory import ( "context" "errors" "fmt" "log/slog" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "galaxy/user/internal/telemetry" ) const ( initialEntitlementSource common.Source = "auth_registration" initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement" initialEntitlementActorType common.ActorType = "service" initialEntitlementActorID common.ActorID = "user-service" ensureCreateRetryLimit = 8 ) // ResolveByEmailInput stores one auth-facing resolve-by-email request. type ResolveByEmailInput struct { // Email stores the caller-supplied e-mail subject. Email string } // ResolveByEmailResult stores one auth-facing resolve-by-email response. type ResolveByEmailResult struct { // Kind stores the coarse user-resolution outcome. Kind string // UserID is present only when Kind is `existing`. UserID string // BlockReasonCode is present only when Kind is `blocked`. BlockReasonCode string } // Resolver executes the auth-facing resolve-by-email use case. type Resolver struct { store ports.AuthDirectoryStore logger *slog.Logger telemetry *telemetry.Runtime } // NewResolver returns one resolve-by-email use case instance. func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) { return NewResolverWithObservability(store, nil, nil) } // NewResolverWithObservability returns one resolve-by-email use case instance // with optional structured logging and metrics hooks. func NewResolverWithObservability( store ports.AuthDirectoryStore, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, ) (*Resolver, error) { if store == nil { return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil") } return &Resolver{ store: store, logger: logger, telemetry: telemetryRuntime, }, nil } // Execute resolves one e-mail subject without creating any account. func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) { outcome := "failed" defer func() { if service.telemetry != nil { service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome) } if err != nil { shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err, "use_case", "resolve_by_email", "outcome", outcome, ) } }() if ctx == nil { return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil") } email, err := shared.ParseEmail(input.Email) if err != nil { return ResolveByEmailResult{}, err } resolution, err := service.store.ResolveByEmail(ctx, email) if err != nil { return ResolveByEmailResult{}, shared.ServiceUnavailable(err) } if err := resolution.Validate(); err != nil { return ResolveByEmailResult{}, shared.InternalError(err) } result = ResolveByEmailResult{ Kind: string(resolution.Kind), } if !resolution.UserID.IsZero() { result.UserID = resolution.UserID.String() } if !resolution.BlockReasonCode.IsZero() { result.BlockReasonCode = resolution.BlockReasonCode.String() } outcome = result.Kind return result, nil } // RegistrationContext stores the create-only auth-facing initialization // context forwarded by authsession. type RegistrationContext struct { // PreferredLanguage stores the initial preferred language. PreferredLanguage string // TimeZone stores the initial declared time-zone name. TimeZone string } // EnsureByEmailInput stores one auth-facing ensure-by-email request. type EnsureByEmailInput struct { // Email stores the caller-supplied e-mail subject. Email string // RegistrationContext stores the required create-only registration context. RegistrationContext *RegistrationContext } // EnsureByEmailResult stores one auth-facing ensure-by-email response. type EnsureByEmailResult struct { // Outcome stores the coarse ensure outcome. Outcome string // UserID is present only for `existing` and `created`. UserID string // BlockReasonCode is present only for `blocked`. BlockReasonCode string } // Ensurer executes the auth-facing ensure-by-email use case. type Ensurer struct { store ports.AuthDirectoryStore clock ports.Clock idGenerator ports.IDGenerator policy ports.RaceNamePolicy logger *slog.Logger telemetry *telemetry.Runtime profilePublisher ports.ProfileChangedPublisher settingsPublisher ports.SettingsChangedPublisher entitlementPublisher ports.EntitlementChangedPublisher } // NewEnsurer returns one ensure-by-email use case instance. func NewEnsurer( store ports.AuthDirectoryStore, clock ports.Clock, idGenerator ports.IDGenerator, policy ports.RaceNamePolicy, ) (*Ensurer, error) { return NewEnsurerWithObservability(store, clock, idGenerator, policy, nil, nil, nil, nil, nil) } // NewEnsurerWithObservability returns one ensure-by-email use case instance // with optional structured logging, metrics, and post-commit event // publication hooks. func NewEnsurerWithObservability( store ports.AuthDirectoryStore, clock ports.Clock, idGenerator ports.IDGenerator, policy ports.RaceNamePolicy, logger *slog.Logger, telemetryRuntime *telemetry.Runtime, profilePublisher ports.ProfileChangedPublisher, settingsPublisher ports.SettingsChangedPublisher, entitlementPublisher ports.EntitlementChangedPublisher, ) (*Ensurer, error) { switch { case store == nil: return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil") case clock == nil: return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil") case idGenerator == nil: return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil") case policy == nil: return nil, fmt.Errorf("authdirectory ensurer: race-name policy must not be nil") default: return &Ensurer{ store: store, clock: clock, idGenerator: idGenerator, policy: policy, logger: logger, telemetry: telemetryRuntime, profilePublisher: profilePublisher, settingsPublisher: settingsPublisher, entitlementPublisher: entitlementPublisher, }, nil } } // Execute ensures that one e-mail subject maps to an existing user, a newly // created user, or a blocked outcome. func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) { outcome := "failed" userIDString := "" defer func() { if service.telemetry != nil { service.telemetry.RecordUserCreationOutcome(ctx, outcome) } shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err, "use_case", "ensure_by_email", "outcome", outcome, "user_id", userIDString, "source", initialEntitlementSource.String(), ) }() if ctx == nil { return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil") } email, err := shared.ParseEmail(input.Email) if err != nil { return EnsureByEmailResult{}, err } if input.RegistrationContext == nil { return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present") } preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage) if err != nil { return EnsureByEmailResult{}, err } timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone) if err != nil { return EnsureByEmailResult{}, err } now := service.clock.Now().UTC() for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ { userID, err := service.idGenerator.NewUserID() if err != nil { return EnsureByEmailResult{}, shared.ServiceUnavailable(err) } raceName, err := service.idGenerator.NewInitialRaceName() if err != nil { return EnsureByEmailResult{}, shared.ServiceUnavailable(err) } accountRecord := account.UserAccount{ UserID: userID, Email: email, RaceName: raceName, PreferredLanguage: preferredLanguage, TimeZone: timeZone, CreatedAt: now, UpdatedAt: now, } entitlementSnapshot := entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: now, Source: initialEntitlementSource, Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID}, ReasonCode: initialEntitlementReasonCode, UpdatedAt: now, } entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID() if err != nil { return EnsureByEmailResult{}, shared.ServiceUnavailable(err) } entitlementRecord := entitlement.PeriodRecord{ RecordID: entitlementRecordID, UserID: userID, PlanCode: entitlement.PlanCodeFree, Source: initialEntitlementSource, Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID}, ReasonCode: initialEntitlementReasonCode, StartsAt: now, CreatedAt: now, } reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now) if err != nil { return EnsureByEmailResult{}, shared.ServiceUnavailable(err) } ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{ Email: email, Account: accountRecord, Entitlement: entitlementSnapshot, EntitlementRecord: entitlementRecord, Reservation: reservation, }) if err != nil { if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil { service.telemetry.RecordRaceNameReservationConflict(ctx, "ensure_by_email") } if errors.Is(err, ports.ErrConflict) { continue } return EnsureByEmailResult{}, shared.ServiceUnavailable(err) } if err := ensureResult.Validate(); err != nil { return EnsureByEmailResult{}, shared.InternalError(err) } result = EnsureByEmailResult{ Outcome: string(ensureResult.Outcome), } if !ensureResult.UserID.IsZero() { result.UserID = ensureResult.UserID.String() userIDString = result.UserID } if !ensureResult.BlockReasonCode.IsZero() { result.BlockReasonCode = ensureResult.BlockReasonCode.String() } outcome = result.Outcome if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) { service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot) } return result, nil } return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded")) } func (service *Ensurer) publishInitializedEvents( ctx context.Context, accountRecord account.UserAccount, entitlementSnapshot entitlement.CurrentSnapshot, ) { occurredAt := accountRecord.UpdatedAt.UTC() service.publishProfileChanged(ctx, ports.ProfileChangedEvent{ UserID: accountRecord.UserID, OccurredAt: occurredAt, Source: initialEntitlementSource, Operation: ports.ProfileChangedOperationInitialized, RaceName: accountRecord.RaceName, }) service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{ UserID: accountRecord.UserID, OccurredAt: occurredAt, Source: initialEntitlementSource, Operation: ports.SettingsChangedOperationInitialized, PreferredLanguage: accountRecord.PreferredLanguage, TimeZone: accountRecord.TimeZone, }) service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{ UserID: entitlementSnapshot.UserID, OccurredAt: occurredAt, Source: initialEntitlementSource, Operation: ports.EntitlementChangedOperationInitialized, PlanCode: entitlementSnapshot.PlanCode, IsPaid: entitlementSnapshot.IsPaid, StartsAt: entitlementSnapshot.StartsAt, EndsAt: entitlementSnapshot.EndsAt, ReasonCode: entitlementSnapshot.ReasonCode, Actor: entitlementSnapshot.Actor, UpdatedAt: entitlementSnapshot.UpdatedAt, }) } func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) { if service.profilePublisher == nil { return } if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err, "use_case", "ensure_by_email", "user_id", event.UserID.String(), "source", event.Source.String(), ) } } func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) { if service.settingsPublisher == nil { return } if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err, "use_case", "ensure_by_email", "user_id", event.UserID.String(), "source", event.Source.String(), ) } } func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) { if service.entitlementPublisher == nil { return } if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil { if service.telemetry != nil { service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType) } shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err, "use_case", "ensure_by_email", "user_id", event.UserID.String(), "source", event.Source.String(), "reason_code", event.ReasonCode.String(), "actor_type", event.Actor.Type.String(), "actor_id", event.Actor.ID.String(), ) } } // ExistsByUserIDInput stores one auth-facing existence check request. type ExistsByUserIDInput struct { // UserID stores the caller-supplied stable user identifier. UserID string } // ExistsByUserIDResult stores one auth-facing existence check response. type ExistsByUserIDResult struct { // Exists reports whether the supplied user identifier currently exists. Exists bool } // ExistenceChecker executes the auth-facing exists-by-user-id use case. type ExistenceChecker struct { store ports.AuthDirectoryStore } // NewExistenceChecker returns one exists-by-user-id use case instance. func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) { if store == nil { return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil") } return &ExistenceChecker{store: store}, nil } // Execute reports whether one stable user identifier exists. func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) { if ctx == nil { return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return ExistsByUserIDResult{}, err } exists, err := service.store.ExistsByUserID(ctx, userID) if err != nil { return ExistsByUserIDResult{}, shared.ServiceUnavailable(err) } return ExistsByUserIDResult{Exists: exists}, nil } // BlockByUserIDInput stores one auth-facing block-by-user-id request. type BlockByUserIDInput struct { // UserID stores the stable account identifier that must be blocked. UserID string // ReasonCode stores the machine-readable block reason. ReasonCode string } // BlockByEmailInput stores one auth-facing block-by-email request. type BlockByEmailInput struct { // Email stores the exact normalized e-mail subject that must be blocked. Email string // ReasonCode stores the machine-readable block reason. ReasonCode string } // BlockResult stores one auth-facing block response. type BlockResult struct { // Outcome reports whether the current call created a new block. Outcome string // UserID stores the resolved account when the blocked subject belongs to an // existing user. UserID string } // BlockByUserIDService executes the auth-facing block-by-user-id use case. type BlockByUserIDService struct { store ports.AuthDirectoryStore clock ports.Clock } // NewBlockByUserIDService returns one block-by-user-id use case instance. func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) { switch { case store == nil: return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil") case clock == nil: return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil") default: return &BlockByUserIDService{store: store, clock: clock}, nil } } // Execute blocks one account addressed by stable user identifier. func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) { if ctx == nil { return BlockResult{}, shared.InvalidRequest("context must not be nil") } userID, err := shared.ParseUserID(input.UserID) if err != nil { return BlockResult{}, err } reasonCode, err := shared.ParseReasonCode(input.ReasonCode) if err != nil { return BlockResult{}, err } result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{ UserID: userID, ReasonCode: reasonCode, BlockedAt: service.clock.Now().UTC(), }) if err != nil { switch { case errors.Is(err, ports.ErrNotFound): return BlockResult{}, shared.SubjectNotFound() default: return BlockResult{}, shared.ServiceUnavailable(err) } } if err := result.Validate(); err != nil { return BlockResult{}, shared.InternalError(err) } response := BlockResult{Outcome: string(result.Outcome)} if !result.UserID.IsZero() { response.UserID = result.UserID.String() } return response, nil } // BlockByEmailService executes the auth-facing block-by-email use case. type BlockByEmailService struct { store ports.AuthDirectoryStore clock ports.Clock } // NewBlockByEmailService returns one block-by-email use case instance. func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) { switch { case store == nil: return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil") case clock == nil: return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil") default: return &BlockByEmailService{store: store, clock: clock}, nil } } // Execute blocks one exact normalized e-mail subject. func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) { if ctx == nil { return BlockResult{}, shared.InvalidRequest("context must not be nil") } email, err := shared.ParseEmail(input.Email) if err != nil { return BlockResult{}, err } reasonCode, err := shared.ParseReasonCode(input.ReasonCode) if err != nil { return BlockResult{}, err } result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{ Email: email, ReasonCode: reasonCode, BlockedAt: service.clock.Now().UTC(), }) if err != nil { return BlockResult{}, shared.ServiceUnavailable(err) } if err := result.Validate(); err != nil { return BlockResult{}, shared.InternalError(err) } response := BlockResult{Outcome: string(result.Outcome)} if !result.UserID.IsZero() { response.UserID = result.UserID.String() } return response, nil }