package deviceactivation import ( "context" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" ) type State string const ( Factory State = "FACTORY" Claimable State = "CLAIMABLE" Bound State = "BOUND" Provisioning State = "PROVISIONING" Active State = "ACTIVE" Lost State = "LOST" Revoked State = "REVOKED" ) var ( ErrInvalidState = errors.New("invalid device activation state") ErrTransitionRejected = errors.New("device activation transition rejected") ErrRevisionConflict = errors.New("device activation revision conflict") ErrDeviceNotFound = errors.New("device activation target not found") ) type Input struct { DeviceID string TenantID string Target State Reason string Source string ActorType string ActorPublicID *string RequestID string ExpectedRevision *int64 } type Result struct { From State To State Revision int64 DeviceVersion int64 UpdatedAt time.Time Changed bool } func Parse(value string) (State, error) { state := State(strings.ToUpper(strings.TrimSpace(value))) switch state { case Factory, Claimable, Bound, Provisioning, Active, Lost, Revoked: return state, nil default: return "", ErrInvalidState } } func AllowsDeviceAuthentication(state string) bool { parsed, err := Parse(state) if err != nil { // Unit-test fakes and rolling deployments may omit the new field. Database // rows are never empty after migration 50. return strings.TrimSpace(state) == "" } return parsed == Claimable || parsed == Bound || parsed == Provisioning || parsed == Active } func CanTransition(from, to State) bool { if from == to { return true } if from == Revoked { return false } if to == Revoked { return true } switch from { case Factory: return to == Claimable case Claimable: return to == Bound case Bound: return to == Claimable || to == Provisioning case Provisioning: return to == Bound || to == Active case Active: return to == Claimable || to == Provisioning || to == Lost case Lost: return to == Claimable || to == Active default: return false } } func Apply(ctx context.Context, tx pgx.Tx, input Input) (Result, error) { if tx == nil || strings.TrimSpace(input.DeviceID) == "" || strings.TrimSpace(input.TenantID) == "" || strings.TrimSpace(input.Reason) == "" || strings.TrimSpace(input.Source) == "" || strings.TrimSpace(input.ActorType) == "" { return Result{}, ErrInvalidState } target, err := Parse(string(input.Target)) if err != nil { return Result{}, err } var currentText string var revision, deviceVersion int64 var updatedAt time.Time err = tx.QueryRow(ctx, ` SELECT activation_state,activation_revision,version,activation_updated_at FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, input.DeviceID, input.TenantID). Scan(¤tText, &revision, &deviceVersion, &updatedAt) if errors.Is(err, pgx.ErrNoRows) { return Result{}, ErrDeviceNotFound } if err != nil { return Result{}, fmt.Errorf("lock device activation: %w", err) } current, err := Parse(currentText) if err != nil { return Result{}, fmt.Errorf("parse stored device activation state: %w", err) } if input.ExpectedRevision != nil && revision != *input.ExpectedRevision { return Result{}, ErrRevisionConflict } if !CanTransition(current, target) { return Result{}, ErrTransitionRejected } if current == target { return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt}, nil } err = tx.QueryRow(ctx, ` UPDATE device SET activation_state=$3,activation_revision=activation_revision+1, activation_reason=$4,activation_updated_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid AND tenant_id=$2::uuid RETURNING activation_revision,version,activation_updated_at`, input.DeviceID, input.TenantID, target, strings.TrimSpace(input.Reason)). Scan(&revision, &deviceVersion, &updatedAt) if err != nil { return Result{}, fmt.Errorf("update device activation: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO device_activation_transition( tenant_id,device_id,from_state,to_state,reason,source,actor_type,actor_public_id,request_id,activation_revision ) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,NULLIF($8,'')::uuid,NULLIF($9,''),$10)`, input.TenantID, input.DeviceID, current, target, strings.TrimSpace(input.Reason), strings.TrimSpace(input.Source), strings.TrimSpace(input.ActorType), optionalString(input.ActorPublicID), strings.TrimSpace(input.RequestID), revision) if err != nil { return Result{}, fmt.Errorf("record device activation transition: %w", err) } return Result{From: current, To: target, Revision: revision, DeviceVersion: deviceVersion, UpdatedAt: updatedAt, Changed: true}, nil } func optionalString(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) }