// Package userservice provides runtime user-directory adapters for the // auth/session service. package userservice import ( "context" "fmt" "sync" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" ) type entry struct { userID common.UserID blockReasonCode userresolution.BlockReasonCode } // StubDirectory is a concurrency-safe in-process UserDirectory stub intended // for development, local integration, and explicit stub-based tests. // // The zero value is ready to use. Unknown e-mail addresses resolve as // creatable, unknown user identifiers do not exist, and EnsureUserByEmail // creates deterministic user ids such as "user-1", "user-2", and so on. type StubDirectory struct { mu sync.Mutex byEmail map[common.Email]entry emailByUserID map[common.UserID]common.Email createdUserIDs []common.UserID nextUserNumber int } // ResolveByEmail returns the current coarse user-resolution state for email // without creating any new user record. func (d *StubDirectory) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) { if err := validateContext(ctx, "resolve by email"); err != nil { return userresolution.Result{}, err } if err := email.Validate(); err != nil { return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err) } d.mu.Lock() defer d.mu.Unlock() result, err := d.resolveLocked(email) if err != nil { return userresolution.Result{}, fmt.Errorf("resolve by email: %w", err) } return result, nil } // ExistsByUserID reports whether userID currently identifies a stored user // record. func (d *StubDirectory) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { if err := validateContext(ctx, "exists by user id"); err != nil { return false, err } if err := userID.Validate(); err != nil { return false, fmt.Errorf("exists by user id: %w", err) } d.mu.Lock() defer d.mu.Unlock() _, ok := d.emailByUserID[userID] return ok, nil } // EnsureUserByEmail returns an existing user for input.Email, creates a new // user when registration is allowed, or reports a blocked outcome. func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) { if err := validateContext(ctx, "ensure user by email"); err != nil { return ports.EnsureUserResult{}, err } if err := input.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } d.mu.Lock() defer d.mu.Unlock() d.ensureMapsLocked() stored, ok := d.byEmail[input.Email] if ok { if !stored.blockReasonCode.IsZero() { result := ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeBlocked, BlockReasonCode: stored.blockReasonCode, } if err := result.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } return result, nil } result := ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeExisting, UserID: stored.userID, } if err := result.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } return result, nil } userID, err := d.nextCreatedUserIDLocked() if err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } d.byEmail[input.Email] = entry{userID: userID} d.emailByUserID[userID] = input.Email result := ports.EnsureUserResult{ Outcome: ports.EnsureUserOutcomeCreated, UserID: userID, } if err := result.Validate(); err != nil { return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err) } return result, nil } // BlockByUserID applies a block state to the user identified by input.UserID. // Unknown user ids wrap ports.ErrNotFound. func (d *StubDirectory) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) { if err := validateContext(ctx, "block by user id"); err != nil { return ports.BlockUserResult{}, err } if err := input.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } d.mu.Lock() defer d.mu.Unlock() email, ok := d.emailByUserID[input.UserID] if !ok { return ports.BlockUserResult{}, fmt.Errorf("block by user id %q: %w", input.UserID, ports.ErrNotFound) } stored := d.byEmail[email] if !stored.blockReasonCode.IsZero() { result := ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeAlreadyBlocked, UserID: input.UserID, } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } return result, nil } stored.blockReasonCode = input.ReasonCode d.byEmail[email] = stored result := ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeBlocked, UserID: input.UserID, } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by user id: %w", err) } return result, nil } // BlockByEmail applies a block state to input.Email even when no user record // currently exists for that e-mail address. func (d *StubDirectory) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) { if err := validateContext(ctx, "block by email"); err != nil { return ports.BlockUserResult{}, err } if err := input.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err) } d.mu.Lock() defer d.mu.Unlock() d.ensureMapsLocked() stored := d.byEmail[input.Email] if !stored.blockReasonCode.IsZero() { result := ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeAlreadyBlocked, UserID: stored.userID, } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err) } return result, nil } stored.blockReasonCode = input.ReasonCode d.byEmail[input.Email] = stored if !stored.userID.IsZero() { d.emailByUserID[stored.userID] = input.Email } result := ports.BlockUserResult{ Outcome: ports.BlockUserOutcomeBlocked, UserID: stored.userID, } if err := result.Validate(); err != nil { return ports.BlockUserResult{}, fmt.Errorf("block by email: %w", err) } return result, nil } // SeedExisting preloads one existing unblocked user record into the runtime // stub. func (d *StubDirectory) SeedExisting(email common.Email, userID common.UserID) error { if err := email.Validate(); err != nil { return fmt.Errorf("seed existing email: %w", err) } if err := userID.Validate(); err != nil { return fmt.Errorf("seed existing user id: %w", err) } d.mu.Lock() defer d.mu.Unlock() d.ensureMapsLocked() d.byEmail[email] = entry{userID: userID} d.emailByUserID[userID] = email return nil } // SeedBlockedEmail preloads one blocked e-mail address that does not // necessarily belong to an existing user record. func (d *StubDirectory) SeedBlockedEmail(email common.Email, reasonCode userresolution.BlockReasonCode) error { if err := email.Validate(); err != nil { return fmt.Errorf("seed blocked email: %w", err) } if err := reasonCode.Validate(); err != nil { return fmt.Errorf("seed blocked email reason code: %w", err) } d.mu.Lock() defer d.mu.Unlock() d.ensureMapsLocked() d.byEmail[email] = entry{blockReasonCode: reasonCode} return nil } // SeedBlockedUser preloads one blocked existing user record into the runtime // stub. func (d *StubDirectory) SeedBlockedUser(email common.Email, userID common.UserID, reasonCode userresolution.BlockReasonCode) error { if err := d.SeedExisting(email, userID); err != nil { return err } d.mu.Lock() defer d.mu.Unlock() stored := d.byEmail[email] stored.blockReasonCode = reasonCode d.byEmail[email] = stored return nil } // QueueCreatedUserIDs appends deterministic user identifiers that // EnsureUserByEmail consumes before falling back to generated ids. func (d *StubDirectory) QueueCreatedUserIDs(userIDs ...common.UserID) error { for index, userID := range userIDs { if err := userID.Validate(); err != nil { return fmt.Errorf("queue created user id %d: %w", index, err) } } d.mu.Lock() defer d.mu.Unlock() d.createdUserIDs = append(d.createdUserIDs, userIDs...) return nil } func (d *StubDirectory) ensureMapsLocked() { if d.byEmail == nil { d.byEmail = make(map[common.Email]entry) } if d.emailByUserID == nil { d.emailByUserID = make(map[common.UserID]common.Email) } } func (d *StubDirectory) resolveLocked(email common.Email) (userresolution.Result, error) { stored, ok := d.byEmail[email] if !ok { result := userresolution.Result{Kind: userresolution.KindCreatable} if err := result.Validate(); err != nil { return userresolution.Result{}, err } return result, nil } if !stored.blockReasonCode.IsZero() { result := userresolution.Result{ Kind: userresolution.KindBlocked, BlockReasonCode: stored.blockReasonCode, } if err := result.Validate(); err != nil { return userresolution.Result{}, err } return result, nil } result := userresolution.Result{ Kind: userresolution.KindExisting, UserID: stored.userID, } if err := result.Validate(); err != nil { return userresolution.Result{}, err } return result, nil } func (d *StubDirectory) nextCreatedUserIDLocked() (common.UserID, error) { if len(d.createdUserIDs) > 0 { userID := d.createdUserIDs[0] d.createdUserIDs = d.createdUserIDs[1:] return userID, nil } d.nextUserNumber++ userID := common.UserID(fmt.Sprintf("user-%d", d.nextUserNumber)) if err := userID.Validate(); err != nil { return "", err } return userID, nil } func validateContext(ctx context.Context, operation string) error { if ctx == nil { return fmt.Errorf("%s: nil context", operation) } if err := ctx.Err(); err != nil { return fmt.Errorf("%s: %w", operation, err) } return nil } var _ ports.UserDirectory = (*StubDirectory)(nil)