feat: user service
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
// Package policy defines sanction, limit, and eligibility-domain entities used
|
||||
// by User Service.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// SanctionCode identifies one supported sanction in the v1 policy catalog.
|
||||
type SanctionCode string
|
||||
|
||||
const (
|
||||
// SanctionCodeLoginBlock denies login.
|
||||
SanctionCodeLoginBlock SanctionCode = "login_block"
|
||||
|
||||
// SanctionCodePrivateGameCreateBlock denies private-game creation.
|
||||
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
|
||||
|
||||
// SanctionCodePrivateGameManageBlock denies private-game management.
|
||||
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
|
||||
|
||||
// SanctionCodeGameJoinBlock denies game joining.
|
||||
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
|
||||
|
||||
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
|
||||
// mutations.
|
||||
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
|
||||
func (code SanctionCode) IsKnown() bool {
|
||||
switch code {
|
||||
case SanctionCodeLoginBlock,
|
||||
SanctionCodePrivateGameCreateBlock,
|
||||
SanctionCodePrivateGameManageBlock,
|
||||
SanctionCodeGameJoinBlock,
|
||||
SanctionCodeProfileUpdateBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LimitCode identifies one user-specific limit code recognized by User
|
||||
// Service.
|
||||
type LimitCode string
|
||||
|
||||
const (
|
||||
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
|
||||
// own while the current entitlement is paid.
|
||||
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
|
||||
// consumed together with current active public memberships when Game Lobby
|
||||
// derives remaining pending application headroom.
|
||||
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
|
||||
|
||||
// LimitCodeMaxActiveGameMemberships limits how many active public-game
|
||||
// memberships the user may hold at once.
|
||||
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
|
||||
)
|
||||
|
||||
const (
|
||||
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
|
||||
// so old stored records do not break current reads.
|
||||
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
|
||||
|
||||
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
|
||||
)
|
||||
|
||||
// IsKnown reports whether LimitCode belongs to the current supported write/API
|
||||
// catalog.
|
||||
func (code LimitCode) IsKnown() bool {
|
||||
return code.IsSupported()
|
||||
}
|
||||
|
||||
// IsSupported reports whether LimitCode belongs to the current supported
|
||||
// write/API catalog.
|
||||
func (code LimitCode) IsSupported() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxOwnedPrivateGames,
|
||||
LimitCodeMaxPendingPublicApplications,
|
||||
LimitCodeMaxActiveGameMemberships:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRetired reports whether LimitCode is a retired legacy code recognized
|
||||
// only for read compatibility with already stored history records.
|
||||
func (code LimitCode) IsRetired() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxActivePrivateGames,
|
||||
LimitCodeMaxPendingPrivateJoinRequests,
|
||||
LimitCodeMaxPendingPrivateInvitesSent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRecognized reports whether LimitCode is either currently supported or
|
||||
// retired-but-recognized for read compatibility.
|
||||
func (code LimitCode) IsRecognized() bool {
|
||||
return code.IsSupported() || code.IsRetired()
|
||||
}
|
||||
|
||||
// EligibilityMarker identifies one derived eligibility boolean that may be
|
||||
// indexed for admin listing.
|
||||
type EligibilityMarker string
|
||||
|
||||
const (
|
||||
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
|
||||
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
|
||||
|
||||
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
|
||||
// a private game.
|
||||
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
|
||||
|
||||
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
|
||||
// a private game.
|
||||
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
|
||||
|
||||
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
|
||||
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
|
||||
|
||||
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
|
||||
// self-service profile/settings fields.
|
||||
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
|
||||
func (marker EligibilityMarker) IsKnown() bool {
|
||||
switch marker {
|
||||
case EligibilityMarkerCanLogin,
|
||||
EligibilityMarkerCanCreatePrivateGame,
|
||||
EligibilityMarkerCanManagePrivateGame,
|
||||
EligibilityMarkerCanJoinGame,
|
||||
EligibilityMarkerCanUpdateProfile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanctionRecordID identifies one sanction history record.
|
||||
type SanctionRecordID string
|
||||
|
||||
// String returns SanctionRecordID as its stored identifier string.
|
||||
func (id SanctionRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether SanctionRecordID does not contain a usable value.
|
||||
func (id SanctionRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecordID is non-empty, normalized, and
|
||||
// uses the frozen Stage 02 prefix.
|
||||
func (id SanctionRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
|
||||
}
|
||||
|
||||
// LimitRecordID identifies one limit history record.
|
||||
type LimitRecordID string
|
||||
|
||||
// String returns LimitRecordID as its stored identifier string.
|
||||
func (id LimitRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether LimitRecordID does not contain a usable value.
|
||||
func (id LimitRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
|
||||
// the frozen Stage 02 prefix.
|
||||
func (id LimitRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("limit record id", string(id), "limit-")
|
||||
}
|
||||
|
||||
// SanctionRecord stores one sanction history record.
|
||||
type SanctionRecord struct {
|
||||
// RecordID identifies the sanction history record.
|
||||
RecordID SanctionRecordID
|
||||
|
||||
// UserID identifies the account that owns the sanction.
|
||||
UserID common.UserID
|
||||
|
||||
// SanctionCode stores the sanction applied to the account.
|
||||
SanctionCode SanctionCode
|
||||
|
||||
// Scope stores the machine-readable scope attached to the sanction.
|
||||
Scope common.Scope
|
||||
|
||||
// ReasonCode stores the reason for the sanction mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the apply mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the sanction becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the sanction.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the sanction was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecord satisfies the frozen structural
|
||||
// invariants that do not depend on a caller-supplied clock.
|
||||
func (record SanctionRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction user id: %w", err)
|
||||
}
|
||||
if !record.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
|
||||
}
|
||||
if err := record.Scope.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction scope: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("sanction removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether SanctionRecord also satisfies the current-time
|
||||
// Stage 02 invariant that `applied_at` must not be in the future.
|
||||
func (record SanctionRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("sanction validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("sanction applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether SanctionRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// LimitRecord stores one user-specific limit history record.
|
||||
type LimitRecord struct {
|
||||
// RecordID identifies the limit history record.
|
||||
RecordID LimitRecordID
|
||||
|
||||
// UserID identifies the account that owns the limit.
|
||||
UserID common.UserID
|
||||
|
||||
// LimitCode stores which count-based limit is overridden.
|
||||
LimitCode LimitCode
|
||||
|
||||
// Value stores the override value.
|
||||
Value int
|
||||
|
||||
// ReasonCode stores the reason for the limit mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the set mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the limit becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the limit.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the limit was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecord satisfies the structural invariants
|
||||
// that do not depend on a caller-supplied clock. Retired legacy limit codes
|
||||
// remain recognized here so already stored records still decode safely.
|
||||
func (record LimitRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit user id: %w", err)
|
||||
}
|
||||
if !record.LimitCode.IsRecognized() {
|
||||
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
|
||||
}
|
||||
if record.Value < 0 {
|
||||
return fmt.Errorf("limit value must not be negative")
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("limit actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("limit removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("limit removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
|
||||
// 02 invariant that `applied_at` must not be in the future.
|
||||
func (record LimitRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("limit validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("limit applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether LimitRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record LimitRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSanctionsAt returns the active sanctions at now, sorted
|
||||
// deterministically by `sanction_code`. The function returns an error when the
|
||||
// input contains structurally invalid records or more than one active sanction
|
||||
// for the same `user_id + sanction_code`.
|
||||
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
|
||||
active := make([]SanctionRecord, 0, len(records))
|
||||
seen := make(map[SanctionCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.SanctionCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
|
||||
}
|
||||
seen[record.SanctionCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
|
||||
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
|
||||
// `limit_code`. Retired legacy limit codes are ignored so historical records
|
||||
// stored under the old catalog do not affect current effective reads. The
|
||||
// function returns an error when the input contains structurally invalid
|
||||
// records or more than one active current limit for the same
|
||||
// `user_id + limit_code`.
|
||||
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
|
||||
active := make([]LimitRecord, 0, len(records))
|
||||
seen := make(map[LimitCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if !record.LimitCode.IsSupported() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.LimitCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
|
||||
}
|
||||
seen[record.LimitCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
|
||||
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func validatePrefixedRecordID(name string, value string, prefix string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
case !strings.HasPrefix(value, prefix):
|
||||
return fmt.Errorf("%s must start with %q", name, prefix)
|
||||
case len(value) == len(prefix):
|
||||
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user