package ports import ( "context" "fmt" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" ) // AuthResolutionKind identifies the coarse auth-facing resolution state of one // e-mail subject. type AuthResolutionKind string const ( // AuthResolutionKindExisting reports that the e-mail belongs to an existing // account. AuthResolutionKindExisting AuthResolutionKind = "existing" // AuthResolutionKindCreatable reports that the e-mail is not blocked and no // account exists yet. AuthResolutionKindCreatable AuthResolutionKind = "creatable" // AuthResolutionKindBlocked reports that the e-mail subject is blocked. AuthResolutionKindBlocked AuthResolutionKind = "blocked" ) // IsKnown reports whether AuthResolutionKind belongs to the supported // auth-facing vocabulary. func (kind AuthResolutionKind) IsKnown() bool { switch kind { case AuthResolutionKindExisting, AuthResolutionKindCreatable, AuthResolutionKindBlocked: return true default: return false } } // ResolveByEmailResult stores the coarse auth-facing state of one e-mail // subject. type ResolveByEmailResult struct { // Kind stores the coarse resolution state. Kind AuthResolutionKind // UserID is present only when Kind is AuthResolutionKindExisting. UserID common.UserID // BlockReasonCode is present only when Kind is AuthResolutionKindBlocked. BlockReasonCode common.ReasonCode } // Validate reports whether ResolveByEmailResult satisfies the auth-facing // invariant set. func (result ResolveByEmailResult) Validate() error { if !result.Kind.IsKnown() { return fmt.Errorf("resolve-by-email result kind %q is unsupported", result.Kind) } switch result.Kind { case AuthResolutionKindExisting: if err := result.UserID.Validate(); err != nil { return fmt.Errorf("resolve-by-email result user id: %w", err) } if !result.BlockReasonCode.IsZero() { return fmt.Errorf("resolve-by-email result block reason code must be empty for existing outcome") } case AuthResolutionKindCreatable: if !result.UserID.IsZero() { return fmt.Errorf("resolve-by-email result user id must be empty for creatable outcome") } if !result.BlockReasonCode.IsZero() { return fmt.Errorf("resolve-by-email result block reason code must be empty for creatable outcome") } case AuthResolutionKindBlocked: if !result.UserID.IsZero() { return fmt.Errorf("resolve-by-email result user id must be empty for blocked outcome") } if err := result.BlockReasonCode.Validate(); err != nil { return fmt.Errorf("resolve-by-email result block reason code: %w", err) } } return nil } // EnsureByEmailOutcome identifies the coarse auth-facing ensure result. type EnsureByEmailOutcome string const ( // EnsureByEmailOutcomeExisting reports that the e-mail already belongs to an // existing account. EnsureByEmailOutcomeExisting EnsureByEmailOutcome = "existing" // EnsureByEmailOutcomeCreated reports that a new account was created. EnsureByEmailOutcomeCreated EnsureByEmailOutcome = "created" // EnsureByEmailOutcomeBlocked reports that creation or reuse is blocked by // policy. EnsureByEmailOutcomeBlocked EnsureByEmailOutcome = "blocked" ) // IsKnown reports whether EnsureByEmailOutcome belongs to the supported // auth-facing vocabulary. func (outcome EnsureByEmailOutcome) IsKnown() bool { switch outcome { case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated, EnsureByEmailOutcomeBlocked: return true default: return false } } // EnsureByEmailInput stores the complete create payload required for atomic // ensure-by-email behavior. type EnsureByEmailInput struct { // Email stores the exact normalized e-mail subject addressed by the ensure // call. Email common.Email // Account stores the fully initialized account that should be persisted when // the e-mail does not yet exist and is not blocked. Account account.UserAccount // Entitlement stores the initial current entitlement snapshot for the new // account. Entitlement entitlement.CurrentSnapshot // EntitlementRecord stores the initial entitlement history record that must // be created atomically with Entitlement. EntitlementRecord entitlement.PeriodRecord // Reservation stores the canonical race-name reservation for Account. Reservation account.RaceNameReservation } // Validate reports whether EnsureByEmailInput is structurally complete. func (input EnsureByEmailInput) Validate() error { if err := input.Email.Validate(); err != nil { return fmt.Errorf("ensure-by-email input email: %w", err) } if err := input.Account.Validate(); err != nil { return fmt.Errorf("ensure-by-email input account: %w", err) } if err := input.Entitlement.Validate(); err != nil { return fmt.Errorf("ensure-by-email input entitlement snapshot: %w", err) } if err := input.EntitlementRecord.Validate(); err != nil { return fmt.Errorf("ensure-by-email input entitlement record: %w", err) } if err := input.Reservation.Validate(); err != nil { return fmt.Errorf("ensure-by-email input reservation: %w", err) } if input.Account.Email != input.Email { return fmt.Errorf("ensure-by-email input account email must match request email") } if input.Account.UserID != input.Entitlement.UserID { return fmt.Errorf("ensure-by-email input account user id must match entitlement user id") } if input.Account.UserID != input.EntitlementRecord.UserID { return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id") } if input.Account.UserID != input.Reservation.UserID { return fmt.Errorf("ensure-by-email input account user id must match reservation user id") } if input.Account.RaceName != input.Reservation.RaceName { return fmt.Errorf("ensure-by-email input account race name must match reservation race name") } if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode { return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code") } if input.EntitlementRecord.Source != input.Entitlement.Source { return fmt.Errorf("ensure-by-email input entitlement record source must match entitlement snapshot source") } if input.EntitlementRecord.Actor != input.Entitlement.Actor { return fmt.Errorf("ensure-by-email input entitlement record actor must match entitlement snapshot actor") } if input.EntitlementRecord.ReasonCode != input.Entitlement.ReasonCode { return fmt.Errorf("ensure-by-email input entitlement record reason code must match entitlement snapshot reason code") } if !input.EntitlementRecord.StartsAt.Equal(input.Entitlement.StartsAt) { return fmt.Errorf("ensure-by-email input entitlement record starts at must match entitlement snapshot starts at") } if !equalOptionalTimes(input.EntitlementRecord.EndsAt, input.Entitlement.EndsAt) { return fmt.Errorf("ensure-by-email input entitlement record ends at must match entitlement snapshot ends at") } return nil } // EnsureByEmailResult stores the coarse auth-facing outcome of an atomic // ensure-by-email call. type EnsureByEmailResult struct { // Outcome stores the coarse ensure result. Outcome EnsureByEmailOutcome // UserID is present only for existing or created outcomes. UserID common.UserID // BlockReasonCode is present only for the blocked outcome. BlockReasonCode common.ReasonCode } // Validate reports whether EnsureByEmailResult satisfies the auth-facing // invariant set. func (result EnsureByEmailResult) Validate() error { if !result.Outcome.IsKnown() { return fmt.Errorf("ensure-by-email result outcome %q is unsupported", result.Outcome) } switch result.Outcome { case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated: if err := result.UserID.Validate(); err != nil { return fmt.Errorf("ensure-by-email result user id: %w", err) } if !result.BlockReasonCode.IsZero() { return fmt.Errorf("ensure-by-email result block reason code must be empty for existing or created outcome") } case EnsureByEmailOutcomeBlocked: if !result.UserID.IsZero() { return fmt.Errorf("ensure-by-email result user id must be empty for blocked outcome") } if err := result.BlockReasonCode.Validate(); err != nil { return fmt.Errorf("ensure-by-email result block reason code: %w", err) } } return nil } // AuthBlockOutcome identifies the coarse result of blocking one auth subject. type AuthBlockOutcome string const ( // AuthBlockOutcomeBlocked reports that the current mutation created a new // block record. AuthBlockOutcomeBlocked AuthBlockOutcome = "blocked" // AuthBlockOutcomeAlreadyBlocked reports that the block already existed. AuthBlockOutcomeAlreadyBlocked AuthBlockOutcome = "already_blocked" ) // IsKnown reports whether AuthBlockOutcome belongs to the supported // auth-facing vocabulary. func (outcome AuthBlockOutcome) IsKnown() bool { switch outcome { case AuthBlockOutcomeBlocked, AuthBlockOutcomeAlreadyBlocked: return true default: return false } } // BlockByUserIDInput stores one auth-facing block request addressed by stable // user identifier. type BlockByUserIDInput struct { // UserID identifies the account that must be blocked. UserID common.UserID // ReasonCode stores the machine-readable block reason. ReasonCode common.ReasonCode // BlockedAt stores the timestamp applied to the blocked e-mail subject // record when a new block is created. BlockedAt time.Time } // Validate reports whether BlockByUserIDInput is structurally complete. func (input BlockByUserIDInput) Validate() error { if err := input.UserID.Validate(); err != nil { return fmt.Errorf("block-by-user-id input user id: %w", err) } if err := input.ReasonCode.Validate(); err != nil { return fmt.Errorf("block-by-user-id input reason code: %w", err) } if err := common.ValidateTimestamp("block-by-user-id input blocked at", input.BlockedAt); err != nil { return err } return nil } // BlockByEmailInput stores one auth-facing block request addressed by exact // normalized e-mail subject. type BlockByEmailInput struct { // Email identifies the e-mail subject that must be blocked. Email common.Email // ReasonCode stores the machine-readable block reason. ReasonCode common.ReasonCode // BlockedAt stores the timestamp applied to the blocked e-mail subject // record when a new block is created. BlockedAt time.Time } // Validate reports whether BlockByEmailInput is structurally complete. func (input BlockByEmailInput) Validate() error { if err := input.Email.Validate(); err != nil { return fmt.Errorf("block-by-email input email: %w", err) } if err := input.ReasonCode.Validate(); err != nil { return fmt.Errorf("block-by-email input reason code: %w", err) } if err := common.ValidateTimestamp("block-by-email input blocked at", input.BlockedAt); err != nil { return err } return nil } // BlockResult stores the coarse auth-facing result of a block mutation. type BlockResult struct { // Outcome reports whether a new block was applied or already existed. Outcome AuthBlockOutcome // UserID stores the resolved account when the blocked subject belongs to one // existing user. UserID common.UserID } // Validate reports whether BlockResult satisfies the auth-facing invariant // set. func (result BlockResult) Validate() error { if !result.Outcome.IsKnown() { return fmt.Errorf("block result outcome %q is unsupported", result.Outcome) } if !result.UserID.IsZero() { if err := result.UserID.Validate(); err != nil { return fmt.Errorf("block result user id: %w", err) } } return nil } // AuthDirectoryStore performs the narrow set of atomic auth-facing reads and // mutations that must not observe inconsistent cross-key Redis state. type AuthDirectoryStore interface { // ResolveByEmail returns the current coarse auth-facing resolution state for // email. ResolveByEmail(ctx context.Context, email common.Email) (ResolveByEmailResult, error) // ExistsByUserID reports whether userID currently identifies a stored // account. ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) // EnsureByEmail returns an existing user, creates a new one, or reports a // blocked outcome atomically for one e-mail subject. EnsureByEmail(ctx context.Context, input EnsureByEmailInput) (EnsureByEmailResult, error) // BlockByUserID applies a block to the account identified by userID. BlockByUserID(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) // BlockByEmail applies a block to email even when no account exists yet. BlockByEmail(ctx context.Context, input BlockByEmailInput) (BlockResult, error) } func equalOptionalTimes(left *time.Time, right *time.Time) bool { switch { case left == nil && right == nil: return true case left == nil || right == nil: return false default: return left.Equal(*right) } }