604 lines
19 KiB
Go
604 lines
19 KiB
Go
// Package confirmemailcode implements the public confirm-email-code use case.
|
|
package confirmemailcode
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/domain/challenge"
|
|
"galaxy/authsession/internal/domain/common"
|
|
"galaxy/authsession/internal/domain/devicesession"
|
|
"galaxy/authsession/internal/domain/sessionlimit"
|
|
"galaxy/authsession/internal/ports"
|
|
"galaxy/authsession/internal/service/shared"
|
|
"galaxy/authsession/internal/telemetry"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
|
|
revokeActorTypeService common.RevokeActorType = "service"
|
|
revokeActorIDService = "confirmemailcode"
|
|
)
|
|
|
|
// Input describes one public confirm-email-code request.
|
|
type Input struct {
|
|
// ChallengeID identifies the challenge that should be confirmed.
|
|
ChallengeID string
|
|
|
|
// Code is the cleartext confirmation code submitted by the caller.
|
|
Code string
|
|
|
|
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
|
|
// should be registered for the created device session.
|
|
ClientPublicKey string
|
|
|
|
// TimeZone is the client-selected IANA time zone name that should be
|
|
// forwarded as create-only registration context when the user does not yet
|
|
// exist.
|
|
TimeZone string
|
|
}
|
|
|
|
// Result describes one public confirm-email-code response.
|
|
type Result struct {
|
|
// DeviceSessionID is the stable identifier of the created or idempotently
|
|
// recovered device session.
|
|
DeviceSessionID string
|
|
}
|
|
|
|
// Service executes the public confirm-email-code use case.
|
|
type Service struct {
|
|
challengeStore ports.ChallengeStore
|
|
sessionStore ports.SessionStore
|
|
userDirectory ports.UserDirectory
|
|
configProvider ports.ConfigProvider
|
|
publisher ports.GatewaySessionProjectionPublisher
|
|
idGenerator ports.IDGenerator
|
|
codeHasher ports.CodeHasher
|
|
clock ports.Clock
|
|
logger *zap.Logger
|
|
telemetry *telemetry.Runtime
|
|
}
|
|
|
|
// New returns a confirm-email-code service wired to the required ports.
|
|
func New(
|
|
challengeStore ports.ChallengeStore,
|
|
sessionStore ports.SessionStore,
|
|
userDirectory ports.UserDirectory,
|
|
configProvider ports.ConfigProvider,
|
|
publisher ports.GatewaySessionProjectionPublisher,
|
|
idGenerator ports.IDGenerator,
|
|
codeHasher ports.CodeHasher,
|
|
clock ports.Clock,
|
|
) (*Service, error) {
|
|
return NewWithTelemetry(
|
|
challengeStore,
|
|
sessionStore,
|
|
userDirectory,
|
|
configProvider,
|
|
publisher,
|
|
idGenerator,
|
|
codeHasher,
|
|
clock,
|
|
nil,
|
|
)
|
|
}
|
|
|
|
// NewWithTelemetry returns a confirm-email-code service wired to the required
|
|
// ports plus the optional Stage-17 telemetry runtime.
|
|
func NewWithTelemetry(
|
|
challengeStore ports.ChallengeStore,
|
|
sessionStore ports.SessionStore,
|
|
userDirectory ports.UserDirectory,
|
|
configProvider ports.ConfigProvider,
|
|
publisher ports.GatewaySessionProjectionPublisher,
|
|
idGenerator ports.IDGenerator,
|
|
codeHasher ports.CodeHasher,
|
|
clock ports.Clock,
|
|
telemetryRuntime *telemetry.Runtime,
|
|
) (*Service, error) {
|
|
return NewWithObservability(
|
|
challengeStore,
|
|
sessionStore,
|
|
userDirectory,
|
|
configProvider,
|
|
publisher,
|
|
idGenerator,
|
|
codeHasher,
|
|
clock,
|
|
nil,
|
|
telemetryRuntime,
|
|
)
|
|
}
|
|
|
|
// NewWithObservability returns a confirm-email-code service wired to the
|
|
// required ports plus optional structured logging and telemetry dependencies.
|
|
func NewWithObservability(
|
|
challengeStore ports.ChallengeStore,
|
|
sessionStore ports.SessionStore,
|
|
userDirectory ports.UserDirectory,
|
|
configProvider ports.ConfigProvider,
|
|
publisher ports.GatewaySessionProjectionPublisher,
|
|
idGenerator ports.IDGenerator,
|
|
codeHasher ports.CodeHasher,
|
|
clock ports.Clock,
|
|
logger *zap.Logger,
|
|
telemetryRuntime *telemetry.Runtime,
|
|
) (*Service, error) {
|
|
switch {
|
|
case challengeStore == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: challenge store must not be nil")
|
|
case sessionStore == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: session store must not be nil")
|
|
case userDirectory == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: user directory must not be nil")
|
|
case configProvider == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: config provider must not be nil")
|
|
case publisher == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: projection publisher must not be nil")
|
|
case idGenerator == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: id generator must not be nil")
|
|
case codeHasher == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: code hasher must not be nil")
|
|
case clock == nil:
|
|
return nil, fmt.Errorf("confirmemailcode: clock must not be nil")
|
|
default:
|
|
return &Service{
|
|
challengeStore: challengeStore,
|
|
sessionStore: sessionStore,
|
|
userDirectory: userDirectory,
|
|
configProvider: configProvider,
|
|
publisher: publisher,
|
|
idGenerator: idGenerator,
|
|
codeHasher: codeHasher,
|
|
clock: clock,
|
|
logger: namedLogger(logger, "confirm_email_code"),
|
|
telemetry: telemetryRuntime,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Execute validates one challenge confirmation attempt, creates a device
|
|
// session when policy allows it, and handles short-window idempotent retries.
|
|
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
|
logFields := []zap.Field{
|
|
zap.String("component", "service"),
|
|
zap.String("use_case", "confirm_email_code"),
|
|
}
|
|
defer func() {
|
|
outcome := string(telemetry.ConfirmEmailCodeOutcomeSuccess)
|
|
if err != nil {
|
|
outcome = shared.CodeOf(err)
|
|
if outcome == "" {
|
|
outcome = shared.ErrorCodeServiceUnavailable
|
|
}
|
|
}
|
|
s.telemetry.RecordConfirmEmailCode(ctx, outcome)
|
|
logFields = append(logFields, zap.String("outcome", outcome))
|
|
if result.DeviceSessionID != "" {
|
|
logFields = append(logFields, zap.String("device_session_id", result.DeviceSessionID))
|
|
}
|
|
shared.LogServiceOutcome(s.logger, ctx, "confirm email code completed", err, logFields...)
|
|
}()
|
|
|
|
challengeID, err := shared.ParseChallengeID(input.ChallengeID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
logFields = append(logFields, zap.String("challenge_id", challengeID.String()))
|
|
code, err := shared.ParseRequiredCode(input.Code)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
clientPublicKey, err := shared.ParseClientPublicKey(input.ClientPublicKey)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
timeZone, err := shared.ParseTimeZone(input.TimeZone)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
|
current, err := s.challengeStore.Get(ctx, challengeID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return Result{}, shared.ChallengeNotFound()
|
|
default:
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
|
|
now := s.clock.Now().UTC()
|
|
if expired, err := s.ensureChallengeNotExpired(ctx, current, now); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
continue
|
|
}
|
|
return Result{}, err
|
|
} else if expired {
|
|
return Result{}, shared.ChallengeExpired()
|
|
}
|
|
|
|
switch {
|
|
case current.Status.IsConfirmedRetryState():
|
|
return s.handleConfirmedRetry(ctx, current, code, clientPublicKey)
|
|
case !current.Status.AcceptsFreshConfirm():
|
|
return Result{}, shared.InvalidCode()
|
|
}
|
|
|
|
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
|
if err != nil {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
if !match {
|
|
if err := s.recordInvalidConfirmAttempt(ctx, current, now); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
continue
|
|
}
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{}, shared.InvalidCode()
|
|
}
|
|
|
|
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{
|
|
Email: current.Email,
|
|
RegistrationContext: &ports.RegistrationContext{
|
|
PreferredLanguage: shared.ResolvePreferredLanguage(current.PreferredLanguage),
|
|
TimeZone: timeZone,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
if err := ensureUserResult.Validate(); err != nil {
|
|
return Result{}, shared.InternalError(err)
|
|
}
|
|
s.telemetry.RecordUserDirectoryOutcome(ctx, "ensure_user_by_email", string(ensureUserResult.Outcome))
|
|
if !ensureUserResult.UserID.IsZero() {
|
|
logFields = append(logFields, zap.String("user_id", ensureUserResult.UserID.String()))
|
|
}
|
|
if ensureUserResult.Outcome == ports.EnsureUserOutcomeBlocked {
|
|
if err := s.markChallengeFailed(ctx, current, now); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
continue
|
|
}
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{}, shared.BlockedByPolicy()
|
|
}
|
|
|
|
limitConfig, err := s.configProvider.LoadSessionLimit(ctx)
|
|
if err != nil {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
decision, err := s.evaluateSessionLimit(ctx, ensureUserResult.UserID, limitConfig)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if decision.Kind == sessionlimit.KindExceeded {
|
|
s.telemetry.RecordSessionLimitRejection(ctx)
|
|
return Result{}, shared.SessionLimitExceeded()
|
|
}
|
|
|
|
sessionRecord, err := s.createSession(ctx, ensureUserResult.UserID, clientPublicKey, now)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
next := current
|
|
next.Status = challenge.StatusConfirmedPendingExpire
|
|
next.ExpiresAt = now.Add(challenge.ConfirmedRetention)
|
|
next.Abuse.LastAttemptAt = &now
|
|
next.Confirmation = &challenge.Confirmation{
|
|
SessionID: sessionRecord.ID,
|
|
ClientPublicKey: clientPublicKey,
|
|
ConfirmedAt: now,
|
|
}
|
|
if err := next.Validate(); err != nil {
|
|
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
|
return Result{}, shared.InternalError(err)
|
|
}
|
|
|
|
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
return s.handleCreateSessionCASConflict(ctx, challengeID, code, clientPublicKey, sessionRecord)
|
|
}
|
|
|
|
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
// Publish the currently stored session view so a concurrent revoke/block
|
|
// cannot overwrite source of truth with a stale active projection.
|
|
currentSession, err := s.sessionStore.Get(ctx, sessionRecord.ID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: newly created session %q was not found", sessionRecord.ID))
|
|
default:
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
if err := s.publishSession(ctx, currentSession, "confirm_email_code"); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{DeviceSessionID: currentSession.ID.String()}, nil
|
|
}
|
|
|
|
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: compare-and-swap retry limit exceeded"))
|
|
}
|
|
|
|
func (s *Service) ensureChallengeNotExpired(ctx context.Context, current challenge.Challenge, now time.Time) (bool, error) {
|
|
if current.IsExpiredAt(now) {
|
|
if current.Status != challenge.StatusExpired && current.Status.CanTransitionTo(challenge.StatusExpired) {
|
|
next := current
|
|
next.Status = challenge.StatusExpired
|
|
next.Abuse.LastAttemptAt = &now
|
|
next.Confirmation = nil
|
|
if err := next.Validate(); err != nil {
|
|
return true, shared.InternalError(err)
|
|
}
|
|
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
|
if !errors.Is(err, ports.ErrConflict) {
|
|
return true, shared.ServiceUnavailable(err)
|
|
}
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Service) handleConfirmedRetry(ctx context.Context, current challenge.Challenge, code string, clientPublicKey common.ClientPublicKey) (Result, error) {
|
|
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
|
if err != nil {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
if !match {
|
|
return Result{}, shared.InvalidCode()
|
|
}
|
|
if current.Confirmation == nil {
|
|
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed challenge is missing confirmation metadata"))
|
|
}
|
|
if current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
|
return Result{}, shared.InvalidCode()
|
|
}
|
|
|
|
record, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed session %q was not found", current.Confirmation.SessionID))
|
|
default:
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
if err := s.publishSession(ctx, record, "confirm_email_code_retry"); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{DeviceSessionID: record.ID.String()}, nil
|
|
}
|
|
|
|
func (s *Service) recordInvalidConfirmAttempt(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
|
next := current
|
|
next.Attempts.Confirm++
|
|
next.Abuse.LastAttemptAt = &now
|
|
if next.Attempts.Confirm >= challenge.MaxInvalidConfirmAttempts {
|
|
next.Status = challenge.StatusFailed
|
|
}
|
|
if err := next.Validate(); err != nil {
|
|
return shared.InternalError(err)
|
|
}
|
|
|
|
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrConflict):
|
|
return err
|
|
default:
|
|
return shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) markChallengeFailed(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
|
next := current
|
|
next.Status = challenge.StatusFailed
|
|
next.Abuse.LastAttemptAt = &now
|
|
if err := next.Validate(); err != nil {
|
|
return shared.InternalError(err)
|
|
}
|
|
|
|
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrConflict):
|
|
return err
|
|
default:
|
|
return shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) evaluateSessionLimit(ctx context.Context, userID common.UserID, config ports.SessionLimitConfig) (sessionlimit.Decision, error) {
|
|
activeSessionCount, err := s.sessionStore.CountActiveByUserID(ctx, userID)
|
|
if err != nil {
|
|
return sessionlimit.Decision{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
decision, err := shared.EvaluateSessionLimit(config, activeSessionCount)
|
|
if err != nil {
|
|
return sessionlimit.Decision{}, err
|
|
}
|
|
|
|
return decision, nil
|
|
}
|
|
|
|
func (s *Service) createSession(ctx context.Context, userID common.UserID, clientPublicKey common.ClientPublicKey, now time.Time) (devicesession.Session, error) {
|
|
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
|
deviceSessionID, err := s.idGenerator.NewDeviceSessionID()
|
|
if err != nil {
|
|
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
record := devicesession.Session{
|
|
ID: deviceSessionID,
|
|
UserID: userID,
|
|
ClientPublicKey: clientPublicKey,
|
|
Status: devicesession.StatusActive,
|
|
CreatedAt: now,
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return devicesession.Session{}, shared.InternalError(err)
|
|
}
|
|
|
|
if err := s.sessionStore.Create(ctx, record); err != nil {
|
|
if errors.Is(err, ports.ErrConflict) {
|
|
continue
|
|
}
|
|
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
|
}
|
|
s.telemetry.RecordSessionCreated(ctx)
|
|
|
|
return record, nil
|
|
}
|
|
|
|
return devicesession.Session{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: session id conflict retry limit exceeded"))
|
|
}
|
|
|
|
func (s *Service) handleCreateSessionCASConflict(
|
|
ctx context.Context,
|
|
challengeID common.ChallengeID,
|
|
code string,
|
|
clientPublicKey common.ClientPublicKey,
|
|
createdSession devicesession.Session,
|
|
) (Result, error) {
|
|
defer s.bestEffortRevokeSupersededSession(ctx, createdSession)
|
|
|
|
current, err := s.challengeStore.Get(ctx, challengeID)
|
|
if err != nil {
|
|
if errors.Is(err, ports.ErrNotFound) {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
|
|
if current.Status != challenge.StatusConfirmedPendingExpire || current.Confirmation == nil {
|
|
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q changed to unexpected status %q after create", challengeID, current.Status))
|
|
}
|
|
|
|
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
|
if err != nil {
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
if !match || current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
|
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q was confirmed by a different payload", challengeID))
|
|
}
|
|
|
|
winningSession, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: winning session %q was not found", current.Confirmation.SessionID))
|
|
default:
|
|
return Result{}, shared.ServiceUnavailable(err)
|
|
}
|
|
}
|
|
if err := s.publishSession(ctx, winningSession, "confirm_email_code_race_winner"); err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
return Result{DeviceSessionID: winningSession.ID.String()}, nil
|
|
}
|
|
|
|
func (s *Service) bestEffortRevokeSupersededSession(ctx context.Context, record devicesession.Session) {
|
|
revocation := devicesession.Revocation{
|
|
At: s.clock.Now().UTC(),
|
|
ReasonCode: revokeReasonConfirmRace,
|
|
ActorType: revokeActorTypeService,
|
|
ActorID: revokeActorIDService,
|
|
}
|
|
if err := revocation.Validate(); err != nil {
|
|
return
|
|
}
|
|
|
|
revokeResult, err := s.sessionStore.Revoke(ctx, ports.RevokeSessionInput{
|
|
DeviceSessionID: record.ID,
|
|
Revocation: revocation,
|
|
})
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"best-effort superseded session revoke failed",
|
|
zap.String("component", "service"),
|
|
zap.String("use_case", "confirm_email_code"),
|
|
zap.String("operation", "confirm_email_code_race_cleanup"),
|
|
zap.String("device_session_id", record.ID.String()),
|
|
zap.String("reason_code", revocation.ReasonCode.String()),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if err := revokeResult.Validate(); err != nil {
|
|
s.logger.Warn(
|
|
"best-effort superseded session revoke produced invalid result",
|
|
zap.String("component", "service"),
|
|
zap.String("use_case", "confirm_email_code"),
|
|
zap.String("operation", "confirm_email_code_race_cleanup"),
|
|
zap.String("device_session_id", record.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if revokeResult.Outcome == ports.RevokeSessionOutcomeRevoked {
|
|
s.telemetry.RecordSessionRevocations(ctx, "confirm_email_code_race_cleanup", revocation.ReasonCode.String(), 1)
|
|
}
|
|
|
|
snapshot, err := shared.ToGatewayProjectionSnapshot(revokeResult.Session)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"best-effort superseded session snapshot mapping failed",
|
|
zap.String("component", "service"),
|
|
zap.String("use_case", "confirm_email_code"),
|
|
zap.String("operation", "confirm_email_code_race_cleanup"),
|
|
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if err := shared.PublishProjectionSnapshotWithTelemetry(ctx, s.publisher, snapshot, s.telemetry, "confirm_email_code_race_cleanup"); err != nil {
|
|
s.logger.Warn(
|
|
"best-effort superseded session publish failed",
|
|
zap.String("component", "service"),
|
|
zap.String("use_case", "confirm_email_code"),
|
|
zap.String("operation", "confirm_email_code_race_cleanup"),
|
|
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (s *Service) publishSession(ctx context.Context, record devicesession.Session, operation string) error {
|
|
return shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, operation)
|
|
}
|
|
|
|
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
|
|
return logger.Named(name)
|
|
}
|