// Package challenge defines the source-of-truth domain model for one e-mail // confirmation challenge. package challenge import ( "errors" "fmt" "time" "galaxy/authsession/internal/domain/common" ) // Status identifies the coarse lifecycle state of one challenge. type Status string const ( // StatusPendingSend reports that the challenge has been created but its // delivery outcome has not been recorded yet. StatusPendingSend Status = "pending_send" // StatusSent reports that the confirmation code was delivered successfully. StatusSent Status = "sent" // StatusDeliverySuppressed reports that outward send succeeded but actual // delivery was intentionally suppressed by policy. StatusDeliverySuppressed Status = "delivery_suppressed" // StatusDeliveryThrottled reports that a fresh challenge was created but // delivery was skipped because the auth-side resend cooldown is still // active. StatusDeliveryThrottled Status = "delivery_throttled" // StatusConfirmedPendingExpire reports that the challenge was confirmed // successfully and is temporarily retained for idempotent retry handling. StatusConfirmedPendingExpire Status = "confirmed_pending_expire" // StatusExpired reports that the challenge can no longer be confirmed. StatusExpired Status = "expired" // StatusFailed reports that the challenge reached a terminal failure state. StatusFailed Status = "failed" // StatusCancelled reports that the challenge was cancelled explicitly. StatusCancelled Status = "cancelled" ) // IsKnown reports whether Status is one of the challenge states supported by // the current domain model. func (s Status) IsKnown() bool { switch s { case StatusPendingSend, StatusSent, StatusDeliverySuppressed, StatusDeliveryThrottled, StatusConfirmedPendingExpire, StatusExpired, StatusFailed, StatusCancelled: return true default: return false } } // IsTerminal reports whether Status can no longer accept any lifecycle // transition in the v1 challenge state machine. func (s Status) IsTerminal() bool { switch s { case StatusExpired, StatusFailed, StatusCancelled: return true default: return false } } // AcceptsFreshConfirm reports whether Status may still consume a first // successful confirmation attempt. func (s Status) AcceptsFreshConfirm() bool { switch s { case StatusSent, StatusDeliverySuppressed: return true default: return false } } // IsConfirmedRetryState reports whether Status should use the idempotent retry // path for a previously successful confirmation. func (s Status) IsConfirmedRetryState() bool { return s == StatusConfirmedPendingExpire } // CanTransitionTo reports whether the current challenge Status may move to // next under the coarse lifecycle rules fixed by Stage 2. func (s Status) CanTransitionTo(next Status) bool { switch s { case StatusPendingSend: switch next { case StatusSent, StatusDeliverySuppressed, StatusDeliveryThrottled, StatusFailed, StatusCancelled, StatusExpired: return true } case StatusSent, StatusDeliverySuppressed: switch next { case StatusConfirmedPendingExpire, StatusFailed, StatusCancelled, StatusExpired: return true } case StatusConfirmedPendingExpire: return next == StatusExpired } return false } // DeliveryState identifies the recorded delivery result of one challenge. type DeliveryState string const ( // DeliveryPending reports that no delivery outcome has been recorded yet. DeliveryPending DeliveryState = "pending" // DeliverySent reports that the challenge code was sent successfully. DeliverySent DeliveryState = "sent" // DeliverySuppressed reports that the outward flow stays success-shaped // while actual delivery is intentionally skipped. DeliverySuppressed DeliveryState = "suppressed" // DeliveryThrottled reports that the outward flow stays success-shaped // while actual delivery is skipped because the resend cooldown is active. DeliveryThrottled DeliveryState = "throttled" // DeliveryFailed reports that delivery was attempted and failed explicitly. DeliveryFailed DeliveryState = "failed" ) // IsKnown reports whether DeliveryState is one of the delivery states // supported by the current domain model. func (s DeliveryState) IsKnown() bool { switch s { case DeliveryPending, DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed: return true default: return false } } // CanTransitionTo reports whether the current DeliveryState may move to next // under the coarse delivery rules fixed by Stage 2. func (s DeliveryState) CanTransitionTo(next DeliveryState) bool { if s != DeliveryPending { return false } switch next { case DeliverySent, DeliverySuppressed, DeliveryThrottled, DeliveryFailed: return true default: return false } } // AttemptCounters groups the mutable send and confirm counters tracked by one // challenge aggregate. type AttemptCounters struct { // Send counts delivery attempts initiated for the challenge. Send int // Confirm counts confirmation attempts evaluated against the challenge. Confirm int } // Validate reports whether AttemptCounters contains only non-negative values. func (c AttemptCounters) Validate() error { if c.Send < 0 { return errors.New("challenge send attempt count must not be negative") } if c.Confirm < 0 { return errors.New("challenge confirm attempt count must not be negative") } return nil } // AbuseMetadata stores minimal abuse-related timestamps without fixing later // anti-abuse policy details too early. type AbuseMetadata struct { // LastAttemptAt optionally records the last send or confirm attempt time // associated with the challenge. LastAttemptAt *time.Time } // Validate reports whether AbuseMetadata contains structurally valid values. func (m AbuseMetadata) Validate() error { if m.LastAttemptAt != nil && m.LastAttemptAt.IsZero() { return errors.New("challenge abuse metadata last attempt time must not be zero") } return nil } // Confirmation stores the idempotency metadata recorded after a successful // challenge confirmation. type Confirmation struct { // SessionID is the created device session returned by the successful // confirmation. SessionID common.DeviceSessionID // ClientPublicKey is the validated client key bound to SessionID. ClientPublicKey common.ClientPublicKey // ConfirmedAt records when the successful confirmation happened. ConfirmedAt time.Time } // Validate reports whether Confirmation contains all metadata required for a // confirmed challenge. func (c Confirmation) Validate() error { if err := c.SessionID.Validate(); err != nil { return fmt.Errorf("challenge confirmation session id: %w", err) } if err := c.ClientPublicKey.Validate(); err != nil { return fmt.Errorf("challenge confirmation client public key: %w", err) } if c.ConfirmedAt.IsZero() { return errors.New("challenge confirmation time must not be zero") } return nil } // Challenge is the minimal source-of-truth aggregate shape fixed by Stage 2. type Challenge struct { // ID identifies the challenge. ID common.ChallengeID // Email stores the normalized target e-mail address. Email common.Email // CodeHash stores only the hashed confirmation code. CodeHash []byte // Status reports the coarse challenge lifecycle state. Status Status // DeliveryState reports the recorded delivery outcome. DeliveryState DeliveryState // CreatedAt reports when the challenge was created. CreatedAt time.Time // ExpiresAt reports when the challenge becomes unusable. ExpiresAt time.Time // Attempts groups the send and confirm counters. Attempts AttemptCounters // Abuse stores minimal abuse-related timestamps. Abuse AbuseMetadata // Confirmation is present only after a successful confirm transition. Confirmation *Confirmation } // IsExpiredAt reports whether the challenge is unusable at now either because // it is already marked expired or because its expiration timestamp has passed. func (c Challenge) IsExpiredAt(now time.Time) bool { return c.Status == StatusExpired || !c.ExpiresAt.After(now) } // Validate reports whether Challenge satisfies the Stage-2 structural and // lifecycle invariants. func (c Challenge) Validate() error { if err := c.ID.Validate(); err != nil { return fmt.Errorf("challenge id: %w", err) } if err := c.Email.Validate(); err != nil { return fmt.Errorf("challenge email: %w", err) } if len(c.CodeHash) == 0 { return errors.New("challenge code hash must not be empty") } if !c.Status.IsKnown() { return fmt.Errorf("challenge status %q is unsupported", c.Status) } if !c.DeliveryState.IsKnown() { return fmt.Errorf("challenge delivery state %q is unsupported", c.DeliveryState) } if c.CreatedAt.IsZero() { return errors.New("challenge creation time must not be zero") } if c.ExpiresAt.IsZero() { return errors.New("challenge expiration time must not be zero") } if c.ExpiresAt.Before(c.CreatedAt) { return errors.New("challenge expiration time must not be before creation time") } if err := c.Attempts.Validate(); err != nil { return err } if err := c.Abuse.Validate(); err != nil { return err } switch c.Status { case StatusPendingSend: if c.DeliveryState != DeliveryPending { return errors.New("pending_send challenge must keep pending delivery state") } case StatusSent: if c.DeliveryState != DeliverySent { return errors.New("sent challenge must keep sent delivery state") } case StatusDeliverySuppressed: if c.DeliveryState != DeliverySuppressed { return errors.New("delivery_suppressed challenge must keep suppressed delivery state") } case StatusDeliveryThrottled: if c.DeliveryState != DeliveryThrottled { return errors.New("delivery_throttled challenge must keep throttled delivery state") } case StatusConfirmedPendingExpire: if c.DeliveryState != DeliverySent && c.DeliveryState != DeliverySuppressed { return errors.New("confirmed_pending_expire challenge must come from sent or suppressed delivery state") } } if c.Status == StatusConfirmedPendingExpire { if c.Confirmation == nil { return errors.New("confirmed_pending_expire challenge must contain confirmation metadata") } if err := c.Confirmation.Validate(); err != nil { return fmt.Errorf("challenge confirmation: %w", err) } return nil } if c.Confirmation != nil { return errors.New("only confirmed_pending_expire challenge may contain confirmation metadata") } return nil }