370 lines
13 KiB
Go
370 lines
13 KiB
Go
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)
|
|
}
|
|
}
|